Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fd4c2f65d | ||
|
|
623ea2e14b | ||
|
|
25bcbad081 | ||
|
|
39427c40e7 | ||
|
|
99e791b6e9 | ||
|
|
b5a288c3de | ||
|
|
9a4394a07c | ||
|
|
54716d705f | ||
|
|
52f6c62fd3 | ||
|
|
27e57b8837 | ||
|
|
80328eddb1 | ||
|
|
a43ef7c242 | ||
|
|
1aa4c42d58 |
21
AGENTS.md
21
AGENTS.md
@ -4,8 +4,8 @@ Go module `git.dev.alexdunmow.com/block/pluginsdk`.
|
||||
|
||||
**Purpose: this is the ONE plugin-facing artifact.** BlockNinja CMS plugins
|
||||
import this module and nothing else from the internal Go tree. Plugins must
|
||||
NOT import `block/core` — core exists only for code shared between first-party
|
||||
entities (cms, orchestrator, ninja CLI).
|
||||
NOT import the first-party `core` module — core exists only for code shared
|
||||
between first-party entities (cms, orchestrator, ninja CLI).
|
||||
|
||||
## The contract is proto, not Go
|
||||
|
||||
@ -15,6 +15,14 @@ entities (cms, orchestrator, ninja CLI).
|
||||
surface here — `plugin/`, `plugin/wasmguest/**`, and the guest-facing type
|
||||
packages — is **one language binding** of the SDK. A non-Go plugin implements
|
||||
the ABI hooks directly and never sees these Go structs.
|
||||
- **The SDK ships no page chrome.** A template renders only its
|
||||
`TemplateDocument`; the **host** composes the document envelope
|
||||
(doctype / `<html>` / `<head>` / `<body>` + bn chrome) around it. Through
|
||||
`templates.PageDocument` a guest supplies the body plus envelope attributes
|
||||
(body class, `lang`, extra `<html>` attributes, and head/body-end extra
|
||||
HTML) — never head/body markup itself. Title, meta, and site chrome are
|
||||
host-authored: the host builds them from the doc map it also hands the guest
|
||||
as `doc_json`.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
@ -25,15 +33,6 @@ entities (cms, orchestrator, ninja CLI).
|
||||
API and update consumers.
|
||||
- Regenerate Go bindings after editing any `.proto`: `make proto`.
|
||||
|
||||
## `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 via cms `make
|
||||
sync-templates`, 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. (This chrome is
|
||||
transitional: it is deleted once host-side head/body injection lands — Phase 3.)
|
||||
|
||||
## Design
|
||||
|
||||
Program spec (in the cms repo):
|
||||
|
||||
18
README.md
18
README.md
@ -1,7 +1,7 @@
|
||||
# block/pluginsdk
|
||||
|
||||
The **plugin-facing SDK** for BlockNinja CMS plugins. Plugins import THIS module,
|
||||
never `block/core`.
|
||||
never the first-party `core` module.
|
||||
|
||||
## What lives here
|
||||
|
||||
@ -16,29 +16,21 @@ never `block/core`.
|
||||
context rehydration), its capability stubs (`plugin/wasmguest/caps/`), and the
|
||||
`database/sql` driver over the host DB (`plugin/wasmguest/bnwasm/`).
|
||||
- **Guest-facing type packages** plugins consume: `blocks/` (incl. `blocks/builtin`,
|
||||
`blocks/shared`, `blocks/tags`), `templates/` (registry + `templates/pongo`),
|
||||
`templates/bn/` (the templ chrome — see sync rule below), `auth/`, `settings/`,
|
||||
`blocks/shared`, `blocks/tags`), `templates/` (the template registry + types,
|
||||
including `PageDocument` — the body-plus-envelope contribution a full-page
|
||||
template makes; the host owns the chrome), `auth/`, `settings/`,
|
||||
`content/`, `gating/`, `crypto/`, `rbac/`, `video/`, `ai/`, `subscriptions/`,
|
||||
`menus/`, `datasources/`.
|
||||
|
||||
The Go surface here is **one language binding**. The proto tree is the contract:
|
||||
a non-Go plugin implements the ABI hooks directly and never sees the Go structs.
|
||||
|
||||
## `templates/bn` synced-copy rule
|
||||
|
||||
`templates/bn/*` is **authored in the cms repo** (`cms/backend/templates/bn`) and
|
||||
**synced into here** via cms `make sync-templates`. Do not hand-edit the copy in
|
||||
this repo — edit it in cms and re-sync. Drift is enforced by check-safety **check 31**
|
||||
(`check-safety/check_templatesync.go`), which compares this repo's `templates/bn`
|
||||
against the cms authoritative copy. (This chrome is transitional: it is deleted from
|
||||
the SDK once host-side head/body injection lands — Phase 3.)
|
||||
|
||||
## Rules
|
||||
|
||||
- **NEVER use `replace` directives** in `go.mod`. Module resolution goes through the
|
||||
Gitea module proxy — to test local changes, tag and push a version.
|
||||
- All consumers are in-house — **no backwards-compatibility shims**.
|
||||
- Plugins import `block/pluginsdk/...`, never `block/core/...`.
|
||||
- Plugins import `block/pluginsdk/...`, never the first-party `core` module.
|
||||
|
||||
## Design
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ syntax = "proto3";
|
||||
package abi.v1;
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
import "v1/http.proto";
|
||||
import "v1/invoke.proto";
|
||||
|
||||
option go_package = "git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1";
|
||||
@ -82,6 +83,18 @@ message PageInfo {
|
||||
string title = 3;
|
||||
}
|
||||
|
||||
// ContentPublishedBlockConfigsRequest asks the host for the stored content
|
||||
// (config JSON) of every block with the given key inside a CURRENTLY
|
||||
// published page snapshot. Lets a guest resolve server-authoritative
|
||||
// per-block settings (e.g. a captcha requirement) it cannot query itself.
|
||||
message ContentPublishedBlockConfigsRequest {
|
||||
string block_key = 1;
|
||||
}
|
||||
|
||||
message ContentPublishedBlockConfigsResponse {
|
||||
repeated bytes configs = 1; // each entry: one block's content as JSON
|
||||
}
|
||||
|
||||
message ContentGetPostRequest {
|
||||
string slug = 1;
|
||||
}
|
||||
@ -298,6 +311,79 @@ message DatasourceResult {
|
||||
bytes meta_json = 3;
|
||||
}
|
||||
|
||||
// --- datatables.* (datatables.DataTables) ---
|
||||
|
||||
message DatatablesGetTableByKeyRequest {
|
||||
string table_key = 1;
|
||||
}
|
||||
|
||||
message DatatablesGetTableByKeyResponse {
|
||||
DataTableInfo table = 1;
|
||||
}
|
||||
|
||||
message DatatablesGetRowRequest {
|
||||
string row_id = 1; // UUID
|
||||
}
|
||||
|
||||
message DatatablesGetRowResponse {
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
message DatatablesListRowsRequest {
|
||||
string table_id = 1; // UUID
|
||||
int32 limit = 2;
|
||||
int32 offset = 3;
|
||||
}
|
||||
|
||||
message DatatablesListRowsResponse {
|
||||
repeated DataTableRowInfo rows = 1;
|
||||
}
|
||||
|
||||
message DatatablesCreateRowRequest {
|
||||
string table_id = 1; // UUID
|
||||
bytes data_json = 2; // JSON object
|
||||
}
|
||||
|
||||
message DatatablesCreateRowResponse {
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
message DatatablesUpdateRowDataRequest {
|
||||
string row_id = 1; // UUID
|
||||
bytes data_json = 2; // JSON object
|
||||
}
|
||||
|
||||
message DatatablesUpdateRowDataResponse {
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
message DatatablesFindRowByFieldRequest {
|
||||
string table_id = 1; // UUID
|
||||
string field = 2;
|
||||
string value = 3;
|
||||
}
|
||||
|
||||
message DatatablesFindRowByFieldResponse {
|
||||
// Unset when no row matches (the guest maps that to nil, nil).
|
||||
DataTableRowInfo row = 1;
|
||||
}
|
||||
|
||||
// DataTableInfo mirrors datatables.Table.
|
||||
message DataTableInfo {
|
||||
string id = 1; // UUID
|
||||
string table_key = 2;
|
||||
string name = 3;
|
||||
int32 row_count = 4;
|
||||
}
|
||||
|
||||
// DataTableRowInfo mirrors datatables.Row (data column as JSON bytes).
|
||||
message DataTableRowInfo {
|
||||
string id = 1; // UUID
|
||||
string table_id = 2; // UUID
|
||||
bytes data_json = 3; // JSON object
|
||||
int32 sort_order = 4;
|
||||
}
|
||||
|
||||
// --- users.* (auth.PublicUsers) ---
|
||||
|
||||
message UsersGetByUsernameRequest {
|
||||
@ -842,3 +928,34 @@ message BridgeInvokeRequest {
|
||||
message BridgeInvokeResponse {
|
||||
bytes payload = 1;
|
||||
}
|
||||
|
||||
// --- http.request (egress.Client host-mediated outbound HTTP, ADR 0023) ---
|
||||
|
||||
// HttpRequestRequest asks the host to perform one outbound HTTPS request on
|
||||
// the plugin's behalf. The host enforces the plugin's egress grant (host
|
||||
// patterns + response cap), the platform denylist, the SSRF guard, and the
|
||||
// redirect policy before any bytes leave — the guest never touches a socket.
|
||||
// Names differ from the HANDLE_HTTP hook pair (HttpRequest/HttpResponse in
|
||||
// http.proto), which is inbound traffic INTO the guest mux.
|
||||
message HttpRequestRequest {
|
||||
string method = 1;
|
||||
// Absolute https URL (scheme required; http is refused by policy).
|
||||
string url = 2;
|
||||
map<string, HeaderValues> headers = 3;
|
||||
bytes body = 4;
|
||||
// Optional guest-side deadline. It may only LOWER the host's per-fetch
|
||||
// ceiling, never raise it; zero means the host ceiling applies.
|
||||
uint32 timeout_ms = 5;
|
||||
}
|
||||
|
||||
// HttpRequestResponse is the fully buffered upstream response. Bodies larger
|
||||
// than the granted cap fail the call (EGRESS_DENIED is policy; oversize is
|
||||
// INTERNAL with a too-large message) — never a silent truncation.
|
||||
message HttpRequestResponse {
|
||||
int32 status = 1;
|
||||
map<string, HeaderValues> headers = 2;
|
||||
bytes body = 3;
|
||||
// URL that produced the response after any (allowlist-validated)
|
||||
// redirects.
|
||||
string final_url = 4;
|
||||
}
|
||||
|
||||
@ -101,6 +101,12 @@ enum AbiErrorCode {
|
||||
// the cms dbexec side (adopted separately) and mapped guest-side to
|
||||
// bnwasm.ErrTxExpired.
|
||||
ABI_ERROR_CODE_TX_EXPIRED = 6;
|
||||
// An http.request egress call was refused by policy (host not granted,
|
||||
// grant revoked, platform-denylisted, or an off-allowlist redirect).
|
||||
// message names the offending host. Mapped guest-side to
|
||||
// egress.ErrDenied — deliberately distinct from a network failure
|
||||
// (ADR 0023, cms repo).
|
||||
ABI_ERROR_CODE_EGRESS_DENIED = 7;
|
||||
}
|
||||
|
||||
// AbiError is the structured error carried by InvokeResponse and
|
||||
|
||||
@ -125,6 +125,48 @@ message PluginManifest {
|
||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||
// plugin.mod + the artifact's declarative files.
|
||||
bool codeless = 33;
|
||||
|
||||
// allowed_hosts declares the egress host patterns the plugin needs for
|
||||
// http.request (exact hostname or left-anchored multi-label wildcard,
|
||||
// e.g. "*.cal.com"; https/443 only unless an entry names an explicit
|
||||
// port). Like data_dir, its source of truth is plugin.mod — the packer
|
||||
// stamps it — because it lives outside the guest code. The host refuses
|
||||
// to load the plugin until an admin grants the full declared set
|
||||
// (ADR 0023, cms repo). Empty means no egress.
|
||||
repeated string allowed_hosts = 34;
|
||||
|
||||
// max_response_mb requests a larger per-request http.request response cap
|
||||
// than the 10 MB default. Rides the same admin grant as allowed_hosts.
|
||||
// Zero means the default.
|
||||
uint32 max_response_mb = 35;
|
||||
|
||||
// public_routes declares the site-root paths the plugin serves through its
|
||||
// HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the host
|
||||
// mounts at the public site root, dispatching to HOOK_HANDLE_HTTP with the
|
||||
// full unstripped request path. Like data_dir and allowed_hosts, the source
|
||||
// of truth is plugin.mod; the packer validates and stamps it. Conflict
|
||||
// rules are host-side: core routes always win, between plugins the
|
||||
// first-installed plugin wins, and conflicts are surfaced in the admin UI,
|
||||
// never silently dropped. Empty means the plugin serves HTTP only under
|
||||
// /api/plugins/<name>.
|
||||
repeated PublicRoute public_routes = 36;
|
||||
|
||||
// sitemap requests sitemap contribution: when true the host fetches
|
||||
// entries from the plugin's well-known sitemap endpoint (served by its
|
||||
// HTTP handler) and merges them into the site sitemap. Requires
|
||||
// has_http_handler.
|
||||
bool sitemap = 37;
|
||||
}
|
||||
|
||||
// PublicRoute is one site-root path claim (see PluginManifest.public_routes).
|
||||
message PublicRoute {
|
||||
// Absolute site-root path, e.g. "/area" or "/api/directory". Validated by
|
||||
// the packer: must start with "/", no reserved prefixes ("/admin",
|
||||
// "/api/plugins", "/ws"), no traversal or empty segments.
|
||||
string path = 1;
|
||||
// When true the claim covers every path under path as well (a path-prefix
|
||||
// mount); when false only the exact path is claimed.
|
||||
bool prefix = 2;
|
||||
}
|
||||
|
||||
// Dependency mirrors plugin.Dependency.
|
||||
|
||||
@ -55,7 +55,27 @@ message RenderTemplateRequest {
|
||||
}
|
||||
|
||||
message RenderTemplateResponse {
|
||||
bytes html = 1;
|
||||
// Field 1 was `bytes html` — the guest-rendered full document, retired
|
||||
// flag-day in P3 (host-side chrome composition). Reserved so a stale
|
||||
// pre-v0.2.0 artifact decodes to an EMPTY document and the host can fail
|
||||
// that render with an explicit republish error instead of mis-parsing.
|
||||
reserved 1;
|
||||
TemplateDocument document = 2;
|
||||
}
|
||||
|
||||
// TemplateDocument is a guest template's contribution to a page. The HOST
|
||||
// owns the document envelope: it composes doctype/<html>/<head>/<body> with
|
||||
// the cms-authored bn chrome, built from the same doc map it sent the guest
|
||||
// as doc_json (see cms docs/superpowers/specs/2026-07-07-host-chrome-injection-design.md).
|
||||
message TemplateDocument {
|
||||
bytes body_html = 1; // inner <body> content
|
||||
string body_class = 2; // <body class="...">
|
||||
string lang = 3; // <html lang>; empty means host default "en"
|
||||
bytes head_extra_html = 4; // appended inside <head>, after bn.Head
|
||||
bytes body_end_extra_html = 5; // appended before </body>, after bn.BodyEnd
|
||||
// Extra <html>-element attributes (e.g. data-theme). A "lang" key here is
|
||||
// ignored — the lang field wins.
|
||||
map<string, string> html_attrs = 6;
|
||||
}
|
||||
|
||||
// RenderTagRequest invokes a plugin-declared template tag (HOOK_RENDER_TAG).
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -168,6 +168,12 @@ const (
|
||||
// the cms dbexec side (adopted separately) and mapped guest-side to
|
||||
// bnwasm.ErrTxExpired.
|
||||
AbiErrorCode_ABI_ERROR_CODE_TX_EXPIRED AbiErrorCode = 6
|
||||
// An http.request egress call was refused by policy (host not granted,
|
||||
// grant revoked, platform-denylisted, or an off-allowlist redirect).
|
||||
// message names the offending host. Mapped guest-side to
|
||||
// egress.ErrDenied — deliberately distinct from a network failure
|
||||
// (ADR 0023, cms repo).
|
||||
AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED AbiErrorCode = 7
|
||||
)
|
||||
|
||||
// Enum value maps for AbiErrorCode.
|
||||
@ -180,6 +186,7 @@ var (
|
||||
4: "ABI_ERROR_CODE_DEADLINE_EXCEEDED",
|
||||
5: "ABI_ERROR_CODE_PERMISSION_DENIED",
|
||||
6: "ABI_ERROR_CODE_TX_EXPIRED",
|
||||
7: "ABI_ERROR_CODE_EGRESS_DENIED",
|
||||
}
|
||||
AbiErrorCode_value = map[string]int32{
|
||||
"ABI_ERROR_CODE_UNSPECIFIED": 0,
|
||||
@ -189,6 +196,7 @@ var (
|
||||
"ABI_ERROR_CODE_DEADLINE_EXCEEDED": 4,
|
||||
"ABI_ERROR_CODE_PERMISSION_DENIED": 5,
|
||||
"ABI_ERROR_CODE_TX_EXPIRED": 6,
|
||||
"ABI_ERROR_CODE_EGRESS_DENIED": 7,
|
||||
}
|
||||
)
|
||||
|
||||
@ -1790,7 +1798,7 @@ const file_v1_invoke_proto_rawDesc = "" +
|
||||
"\x11HOOK_AI_TOOL_CALL\x10\f\x12\x14\n" +
|
||||
"\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" +
|
||||
"\x1cHOOK_DIRECTORY_PANEL_SECTION\x10\x0e\x12 \n" +
|
||||
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\xf3\x01\n" +
|
||||
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\x95\x02\n" +
|
||||
"\fAbiErrorCode\x12\x1e\n" +
|
||||
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
|
||||
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" +
|
||||
@ -1798,7 +1806,8 @@ const file_v1_invoke_proto_rawDesc = "" +
|
||||
"\x1cABI_ERROR_CODE_UNIMPLEMENTED\x10\x03\x12$\n" +
|
||||
" ABI_ERROR_CODE_DEADLINE_EXCEEDED\x10\x04\x12$\n" +
|
||||
" ABI_ERROR_CODE_PERMISSION_DENIED\x10\x05\x12\x1d\n" +
|
||||
"\x19ABI_ERROR_CODE_TX_EXPIRED\x10\x06B5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3"
|
||||
"\x19ABI_ERROR_CODE_TX_EXPIRED\x10\x06\x12 \n" +
|
||||
"\x1cABI_ERROR_CODE_EGRESS_DENIED\x10\aB5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3"
|
||||
|
||||
var (
|
||||
file_v1_invoke_proto_rawDescOnce sync.Once
|
||||
|
||||
@ -122,7 +122,34 @@ type PluginManifest struct {
|
||||
// both reject the combination. Not produced by DESCRIBE (there is no guest
|
||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||
// plugin.mod + the artifact's declarative files.
|
||||
Codeless bool `protobuf:"varint,33,opt,name=codeless,proto3" json:"codeless,omitempty"`
|
||||
Codeless bool `protobuf:"varint,33,opt,name=codeless,proto3" json:"codeless,omitempty"`
|
||||
// allowed_hosts declares the egress host patterns the plugin needs for
|
||||
// http.request (exact hostname or left-anchored multi-label wildcard,
|
||||
// e.g. "*.cal.com"; https/443 only unless an entry names an explicit
|
||||
// port). Like data_dir, its source of truth is plugin.mod — the packer
|
||||
// stamps it — because it lives outside the guest code. The host refuses
|
||||
// to load the plugin until an admin grants the full declared set
|
||||
// (ADR 0023, cms repo). Empty means no egress.
|
||||
AllowedHosts []string `protobuf:"bytes,34,rep,name=allowed_hosts,json=allowedHosts,proto3" json:"allowed_hosts,omitempty"`
|
||||
// max_response_mb requests a larger per-request http.request response cap
|
||||
// than the 10 MB default. Rides the same admin grant as allowed_hosts.
|
||||
// Zero means the default.
|
||||
MaxResponseMb uint32 `protobuf:"varint,35,opt,name=max_response_mb,json=maxResponseMb,proto3" json:"max_response_mb,omitempty"`
|
||||
// public_routes declares the site-root paths the plugin serves through its
|
||||
// HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the host
|
||||
// mounts at the public site root, dispatching to HOOK_HANDLE_HTTP with the
|
||||
// full unstripped request path. Like data_dir and allowed_hosts, the source
|
||||
// of truth is plugin.mod; the packer validates and stamps it. Conflict
|
||||
// rules are host-side: core routes always win, between plugins the
|
||||
// first-installed plugin wins, and conflicts are surfaced in the admin UI,
|
||||
// never silently dropped. Empty means the plugin serves HTTP only under
|
||||
// /api/plugins/<name>.
|
||||
PublicRoutes []*PublicRoute `protobuf:"bytes,36,rep,name=public_routes,json=publicRoutes,proto3" json:"public_routes,omitempty"`
|
||||
// sitemap requests sitemap contribution: when true the host fetches
|
||||
// entries from the plugin's well-known sitemap endpoint (served by its
|
||||
// HTTP handler) and merges them into the site sitemap. Requires
|
||||
// has_http_handler.
|
||||
Sitemap bool `protobuf:"varint,37,opt,name=sitemap,proto3" json:"sitemap,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -388,6 +415,92 @@ func (x *PluginManifest) GetCodeless() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *PluginManifest) GetAllowedHosts() []string {
|
||||
if x != nil {
|
||||
return x.AllowedHosts
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *PluginManifest) GetMaxResponseMb() uint32 {
|
||||
if x != nil {
|
||||
return x.MaxResponseMb
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *PluginManifest) GetPublicRoutes() []*PublicRoute {
|
||||
if x != nil {
|
||||
return x.PublicRoutes
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *PluginManifest) GetSitemap() bool {
|
||||
if x != nil {
|
||||
return x.Sitemap
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// PublicRoute is one site-root path claim (see PluginManifest.public_routes).
|
||||
type PublicRoute struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Absolute site-root path, e.g. "/area" or "/api/directory". Validated by
|
||||
// the packer: must start with "/", no reserved prefixes ("/admin",
|
||||
// "/api/plugins", "/ws"), no traversal or empty segments.
|
||||
Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"`
|
||||
// When true the claim covers every path under path as well (a path-prefix
|
||||
// mount); when false only the exact path is claimed.
|
||||
Prefix bool `protobuf:"varint,2,opt,name=prefix,proto3" json:"prefix,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *PublicRoute) Reset() {
|
||||
*x = PublicRoute{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *PublicRoute) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*PublicRoute) ProtoMessage() {}
|
||||
|
||||
func (x *PublicRoute) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use PublicRoute.ProtoReflect.Descriptor instead.
|
||||
func (*PublicRoute) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{1}
|
||||
}
|
||||
|
||||
func (x *PublicRoute) GetPath() string {
|
||||
if x != nil {
|
||||
return x.Path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *PublicRoute) GetPrefix() bool {
|
||||
if x != nil {
|
||||
return x.Prefix
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Dependency mirrors plugin.Dependency.
|
||||
type Dependency struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@ -400,7 +513,7 @@ type Dependency struct {
|
||||
|
||||
func (x *Dependency) Reset() {
|
||||
*x = Dependency{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -412,7 +525,7 @@ func (x *Dependency) String() string {
|
||||
func (*Dependency) ProtoMessage() {}
|
||||
|
||||
func (x *Dependency) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -425,7 +538,7 @@ func (x *Dependency) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use Dependency.ProtoReflect.Descriptor instead.
|
||||
func (*Dependency) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{1}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{2}
|
||||
}
|
||||
|
||||
func (x *Dependency) GetPlugin() string {
|
||||
@ -468,7 +581,7 @@ type BlockMeta struct {
|
||||
|
||||
func (x *BlockMeta) Reset() {
|
||||
*x = BlockMeta{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -480,7 +593,7 @@ func (x *BlockMeta) String() string {
|
||||
func (*BlockMeta) ProtoMessage() {}
|
||||
|
||||
func (x *BlockMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -493,7 +606,7 @@ func (x *BlockMeta) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead.
|
||||
func (*BlockMeta) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{2}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{3}
|
||||
}
|
||||
|
||||
func (x *BlockMeta) GetKey() string {
|
||||
@ -559,7 +672,7 @@ type BlockTemplateOverride struct {
|
||||
|
||||
func (x *BlockTemplateOverride) Reset() {
|
||||
*x = BlockTemplateOverride{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -571,7 +684,7 @@ func (x *BlockTemplateOverride) String() string {
|
||||
func (*BlockTemplateOverride) ProtoMessage() {}
|
||||
|
||||
func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -584,7 +697,7 @@ func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead.
|
||||
func (*BlockTemplateOverride) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{3}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *BlockTemplateOverride) GetTemplateKey() string {
|
||||
@ -620,7 +733,7 @@ type SystemTemplateMeta struct {
|
||||
|
||||
func (x *SystemTemplateMeta) Reset() {
|
||||
*x = SystemTemplateMeta{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -632,7 +745,7 @@ func (x *SystemTemplateMeta) String() string {
|
||||
func (*SystemTemplateMeta) ProtoMessage() {}
|
||||
|
||||
func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -645,7 +758,7 @@ func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead.
|
||||
func (*SystemTemplateMeta) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{4}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *SystemTemplateMeta) GetKey() string {
|
||||
@ -684,7 +797,7 @@ type PageTemplateMeta struct {
|
||||
|
||||
func (x *PageTemplateMeta) Reset() {
|
||||
*x = PageTemplateMeta{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -696,7 +809,7 @@ func (x *PageTemplateMeta) String() string {
|
||||
func (*PageTemplateMeta) ProtoMessage() {}
|
||||
|
||||
func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -709,7 +822,7 @@ func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead.
|
||||
func (*PageTemplateMeta) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{5}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *PageTemplateMeta) GetSystemKey() string {
|
||||
@ -760,7 +873,7 @@ type AdminPage struct {
|
||||
|
||||
func (x *AdminPage) Reset() {
|
||||
*x = AdminPage{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -772,7 +885,7 @@ func (x *AdminPage) String() string {
|
||||
func (*AdminPage) ProtoMessage() {}
|
||||
|
||||
func (x *AdminPage) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -785,7 +898,7 @@ func (x *AdminPage) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AdminPage.ProtoReflect.Descriptor instead.
|
||||
func (*AdminPage) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{6}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *AdminPage) GetKey() string {
|
||||
@ -830,7 +943,7 @@ type AiAction struct {
|
||||
|
||||
func (x *AiAction) Reset() {
|
||||
*x = AiAction{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -842,7 +955,7 @@ func (x *AiAction) String() string {
|
||||
func (*AiAction) ProtoMessage() {}
|
||||
|
||||
func (x *AiAction) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -855,7 +968,7 @@ func (x *AiAction) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AiAction.ProtoReflect.Descriptor instead.
|
||||
func (*AiAction) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{7}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *AiAction) GetKey() string {
|
||||
@ -905,7 +1018,7 @@ type CssManifest struct {
|
||||
|
||||
func (x *CssManifest) Reset() {
|
||||
*x = CssManifest{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -917,7 +1030,7 @@ func (x *CssManifest) String() string {
|
||||
func (*CssManifest) ProtoMessage() {}
|
||||
|
||||
func (x *CssManifest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -930,7 +1043,7 @@ func (x *CssManifest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use CssManifest.ProtoReflect.Descriptor instead.
|
||||
func (*CssManifest) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{8}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *CssManifest) GetNpmPackages() map[string]string {
|
||||
@ -971,7 +1084,7 @@ type DirectoryExtensions struct {
|
||||
|
||||
func (x *DirectoryExtensions) Reset() {
|
||||
*x = DirectoryExtensions{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -983,7 +1096,7 @@ func (x *DirectoryExtensions) String() string {
|
||||
func (*DirectoryExtensions) ProtoMessage() {}
|
||||
|
||||
func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -996,7 +1109,7 @@ func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead.
|
||||
func (*DirectoryExtensions) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{9}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *DirectoryExtensions) GetBooleanFilterFields() []string {
|
||||
@ -1045,7 +1158,7 @@ type BadgeLabel struct {
|
||||
|
||||
func (x *BadgeLabel) Reset() {
|
||||
*x = BadgeLabel{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1057,7 +1170,7 @@ func (x *BadgeLabel) String() string {
|
||||
func (*BadgeLabel) ProtoMessage() {}
|
||||
|
||||
func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1070,7 +1183,7 @@ func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead.
|
||||
func (*BadgeLabel) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{10}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *BadgeLabel) GetPositive() string {
|
||||
@ -1100,7 +1213,7 @@ type MasterPageDefinition struct {
|
||||
|
||||
func (x *MasterPageDefinition) Reset() {
|
||||
*x = MasterPageDefinition{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1112,7 +1225,7 @@ func (x *MasterPageDefinition) String() string {
|
||||
func (*MasterPageDefinition) ProtoMessage() {}
|
||||
|
||||
func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1125,7 +1238,7 @@ func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead.
|
||||
func (*MasterPageDefinition) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{11}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *MasterPageDefinition) GetKey() string {
|
||||
@ -1172,7 +1285,7 @@ type MasterPageBlock struct {
|
||||
|
||||
func (x *MasterPageBlock) Reset() {
|
||||
*x = MasterPageBlock{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1184,7 +1297,7 @@ func (x *MasterPageBlock) String() string {
|
||||
func (*MasterPageBlock) ProtoMessage() {}
|
||||
|
||||
func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1197,7 +1310,7 @@ func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead.
|
||||
func (*MasterPageBlock) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{12}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *MasterPageBlock) GetBlockKey() string {
|
||||
@ -1254,7 +1367,7 @@ type CoreServiceBinding struct {
|
||||
|
||||
func (x *CoreServiceBinding) Reset() {
|
||||
*x = CoreServiceBinding{}
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
mi := &file_v1_manifest_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1266,7 +1379,7 @@ func (x *CoreServiceBinding) String() string {
|
||||
func (*CoreServiceBinding) ProtoMessage() {}
|
||||
|
||||
func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||
mi := &file_v1_manifest_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1279,7 +1392,7 @@ func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead.
|
||||
func (*CoreServiceBinding) Descriptor() ([]byte, []int) {
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{13}
|
||||
return file_v1_manifest_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *CoreServiceBinding) GetServiceName() string {
|
||||
@ -1300,7 +1413,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
|
||||
|
||||
const file_v1_manifest_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\x8e\r\n" +
|
||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\xaf\x0e\n" +
|
||||
"\x0ePluginManifest\x12\x1f\n" +
|
||||
"\vabi_version\x18\x01 \x01(\rR\n" +
|
||||
"abiVersion\x12\x12\n" +
|
||||
@ -1338,10 +1451,17 @@ const file_v1_manifest_proto_rawDesc = "" +
|
||||
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
|
||||
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
|
||||
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" +
|
||||
"\bcodeless\x18! \x01(\bR\bcodeless\x1aB\n" +
|
||||
"\bcodeless\x18! \x01(\bR\bcodeless\x12#\n" +
|
||||
"\rallowed_hosts\x18\" \x03(\tR\fallowedHosts\x12&\n" +
|
||||
"\x0fmax_response_mb\x18# \x01(\rR\rmaxResponseMb\x128\n" +
|
||||
"\rpublic_routes\x18$ \x03(\v2\x13.abi.v1.PublicRouteR\fpublicRoutes\x12\x18\n" +
|
||||
"\asitemap\x18% \x01(\bR\asitemap\x1aB\n" +
|
||||
"\x14RbacMethodRolesEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"9\n" +
|
||||
"\vPublicRoute\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" +
|
||||
"\x06prefix\x18\x02 \x01(\bR\x06prefix\"a\n" +
|
||||
"\n" +
|
||||
"Dependency\x12\x16\n" +
|
||||
"\x06plugin\x18\x01 \x01(\tR\x06plugin\x12\x1f\n" +
|
||||
@ -1435,50 +1555,52 @@ func file_v1_manifest_proto_rawDescGZIP() []byte {
|
||||
return file_v1_manifest_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
|
||||
var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
|
||||
var file_v1_manifest_proto_goTypes = []any{
|
||||
(*PluginManifest)(nil), // 0: abi.v1.PluginManifest
|
||||
(*Dependency)(nil), // 1: abi.v1.Dependency
|
||||
(*BlockMeta)(nil), // 2: abi.v1.BlockMeta
|
||||
(*BlockTemplateOverride)(nil), // 3: abi.v1.BlockTemplateOverride
|
||||
(*SystemTemplateMeta)(nil), // 4: abi.v1.SystemTemplateMeta
|
||||
(*PageTemplateMeta)(nil), // 5: abi.v1.PageTemplateMeta
|
||||
(*AdminPage)(nil), // 6: abi.v1.AdminPage
|
||||
(*AiAction)(nil), // 7: abi.v1.AiAction
|
||||
(*CssManifest)(nil), // 8: abi.v1.CssManifest
|
||||
(*DirectoryExtensions)(nil), // 9: abi.v1.DirectoryExtensions
|
||||
(*BadgeLabel)(nil), // 10: abi.v1.BadgeLabel
|
||||
(*MasterPageDefinition)(nil), // 11: abi.v1.MasterPageDefinition
|
||||
(*MasterPageBlock)(nil), // 12: abi.v1.MasterPageBlock
|
||||
(*CoreServiceBinding)(nil), // 13: abi.v1.CoreServiceBinding
|
||||
nil, // 14: abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||
nil, // 15: abi.v1.CssManifest.NpmPackagesEntry
|
||||
nil, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
nil, // 17: abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
(*PublicRoute)(nil), // 1: abi.v1.PublicRoute
|
||||
(*Dependency)(nil), // 2: abi.v1.Dependency
|
||||
(*BlockMeta)(nil), // 3: abi.v1.BlockMeta
|
||||
(*BlockTemplateOverride)(nil), // 4: abi.v1.BlockTemplateOverride
|
||||
(*SystemTemplateMeta)(nil), // 5: abi.v1.SystemTemplateMeta
|
||||
(*PageTemplateMeta)(nil), // 6: abi.v1.PageTemplateMeta
|
||||
(*AdminPage)(nil), // 7: abi.v1.AdminPage
|
||||
(*AiAction)(nil), // 8: abi.v1.AiAction
|
||||
(*CssManifest)(nil), // 9: abi.v1.CssManifest
|
||||
(*DirectoryExtensions)(nil), // 10: abi.v1.DirectoryExtensions
|
||||
(*BadgeLabel)(nil), // 11: abi.v1.BadgeLabel
|
||||
(*MasterPageDefinition)(nil), // 12: abi.v1.MasterPageDefinition
|
||||
(*MasterPageBlock)(nil), // 13: abi.v1.MasterPageBlock
|
||||
(*CoreServiceBinding)(nil), // 14: abi.v1.CoreServiceBinding
|
||||
nil, // 15: abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||
nil, // 16: abi.v1.CssManifest.NpmPackagesEntry
|
||||
nil, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
nil, // 18: abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
}
|
||||
var file_v1_manifest_proto_depIdxs = []int32{
|
||||
1, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency
|
||||
2, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta
|
||||
3, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride
|
||||
4, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta
|
||||
5, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta
|
||||
6, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage
|
||||
11, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition
|
||||
7, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction
|
||||
14, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||
8, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest
|
||||
9, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions
|
||||
13, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding
|
||||
15, // 12: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry
|
||||
16, // 13: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
12, // 14: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock
|
||||
17, // 15: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
10, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel
|
||||
17, // [17:17] is the sub-list for method output_type
|
||||
17, // [17:17] is the sub-list for method input_type
|
||||
17, // [17:17] is the sub-list for extension type_name
|
||||
17, // [17:17] is the sub-list for extension extendee
|
||||
0, // [0:17] is the sub-list for field type_name
|
||||
2, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency
|
||||
3, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta
|
||||
4, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride
|
||||
5, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta
|
||||
6, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta
|
||||
7, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage
|
||||
12, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition
|
||||
8, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction
|
||||
15, // 8: abi.v1.PluginManifest.rbac_method_roles:type_name -> abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||
9, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest
|
||||
10, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions
|
||||
14, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding
|
||||
1, // 12: abi.v1.PluginManifest.public_routes:type_name -> abi.v1.PublicRoute
|
||||
16, // 13: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry
|
||||
17, // 14: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||
13, // 15: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock
|
||||
18, // 16: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||
11, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel
|
||||
18, // [18:18] is the sub-list for method output_type
|
||||
18, // [18:18] is the sub-list for method input_type
|
||||
18, // [18:18] is the sub-list for extension type_name
|
||||
18, // [18:18] is the sub-list for extension extendee
|
||||
0, // [0:18] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_v1_manifest_proto_init() }
|
||||
@ -1486,14 +1608,14 @@ func file_v1_manifest_proto_init() {
|
||||
if File_v1_manifest_proto != nil {
|
||||
return
|
||||
}
|
||||
file_v1_manifest_proto_msgTypes[12].OneofWrappers = []any{}
|
||||
file_v1_manifest_proto_msgTypes[13].OneofWrappers = []any{}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 18,
|
||||
NumMessages: 19,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@ -274,7 +274,7 @@ func (x *RenderTemplateRequest) GetRenderContext() *RenderContext {
|
||||
|
||||
type RenderTemplateResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Html []byte `protobuf:"bytes,1,opt,name=html,proto3" json:"html,omitempty"`
|
||||
Document *TemplateDocument `protobuf:"bytes,2,opt,name=document,proto3" json:"document,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@ -309,9 +309,99 @@ func (*RenderTemplateResponse) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{4}
|
||||
}
|
||||
|
||||
func (x *RenderTemplateResponse) GetHtml() []byte {
|
||||
func (x *RenderTemplateResponse) GetDocument() *TemplateDocument {
|
||||
if x != nil {
|
||||
return x.Html
|
||||
return x.Document
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TemplateDocument is a guest template's contribution to a page. The HOST
|
||||
// owns the document envelope: it composes doctype/<html>/<head>/<body> with
|
||||
// the cms-authored bn chrome, built from the same doc map it sent the guest
|
||||
// as doc_json (see cms docs/superpowers/specs/2026-07-07-host-chrome-injection-design.md).
|
||||
type TemplateDocument struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
BodyHtml []byte `protobuf:"bytes,1,opt,name=body_html,json=bodyHtml,proto3" json:"body_html,omitempty"` // inner <body> content
|
||||
BodyClass string `protobuf:"bytes,2,opt,name=body_class,json=bodyClass,proto3" json:"body_class,omitempty"` // <body class="...">
|
||||
Lang string `protobuf:"bytes,3,opt,name=lang,proto3" json:"lang,omitempty"` // <html lang>; empty means host default "en"
|
||||
HeadExtraHtml []byte `protobuf:"bytes,4,opt,name=head_extra_html,json=headExtraHtml,proto3" json:"head_extra_html,omitempty"` // appended inside <head>, after bn.Head
|
||||
BodyEndExtraHtml []byte `protobuf:"bytes,5,opt,name=body_end_extra_html,json=bodyEndExtraHtml,proto3" json:"body_end_extra_html,omitempty"` // appended before </body>, after bn.BodyEnd
|
||||
// Extra <html>-element attributes (e.g. data-theme). A "lang" key here is
|
||||
// ignored — the lang field wins.
|
||||
HtmlAttrs map[string]string `protobuf:"bytes,6,rep,name=html_attrs,json=htmlAttrs,proto3" json:"html_attrs,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) Reset() {
|
||||
*x = TemplateDocument{}
|
||||
mi := &file_v1_render_proto_msgTypes[5]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) String() string {
|
||||
return protoimpl.X.MessageStringOf(x)
|
||||
}
|
||||
|
||||
func (*TemplateDocument) ProtoMessage() {}
|
||||
|
||||
func (x *TemplateDocument) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[5]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
return ms
|
||||
}
|
||||
return mi.MessageOf(x)
|
||||
}
|
||||
|
||||
// Deprecated: Use TemplateDocument.ProtoReflect.Descriptor instead.
|
||||
func (*TemplateDocument) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{5}
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) GetBodyHtml() []byte {
|
||||
if x != nil {
|
||||
return x.BodyHtml
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) GetBodyClass() string {
|
||||
if x != nil {
|
||||
return x.BodyClass
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) GetLang() string {
|
||||
if x != nil {
|
||||
return x.Lang
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) GetHeadExtraHtml() []byte {
|
||||
if x != nil {
|
||||
return x.HeadExtraHtml
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) GetBodyEndExtraHtml() []byte {
|
||||
if x != nil {
|
||||
return x.BodyEndExtraHtml
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *TemplateDocument) GetHtmlAttrs() map[string]string {
|
||||
if x != nil {
|
||||
return x.HtmlAttrs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -334,7 +424,7 @@ type RenderTagRequest struct {
|
||||
|
||||
func (x *RenderTagRequest) Reset() {
|
||||
*x = RenderTagRequest{}
|
||||
mi := &file_v1_render_proto_msgTypes[5]
|
||||
mi := &file_v1_render_proto_msgTypes[6]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -346,7 +436,7 @@ func (x *RenderTagRequest) String() string {
|
||||
func (*RenderTagRequest) ProtoMessage() {}
|
||||
|
||||
func (x *RenderTagRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[5]
|
||||
mi := &file_v1_render_proto_msgTypes[6]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -359,7 +449,7 @@ func (x *RenderTagRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RenderTagRequest.ProtoReflect.Descriptor instead.
|
||||
func (*RenderTagRequest) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{5}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{6}
|
||||
}
|
||||
|
||||
func (x *RenderTagRequest) GetTagName() string {
|
||||
@ -396,7 +486,7 @@ type RenderTagResponse struct {
|
||||
|
||||
func (x *RenderTagResponse) Reset() {
|
||||
*x = RenderTagResponse{}
|
||||
mi := &file_v1_render_proto_msgTypes[6]
|
||||
mi := &file_v1_render_proto_msgTypes[7]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -408,7 +498,7 @@ func (x *RenderTagResponse) String() string {
|
||||
func (*RenderTagResponse) ProtoMessage() {}
|
||||
|
||||
func (x *RenderTagResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[6]
|
||||
mi := &file_v1_render_proto_msgTypes[7]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -421,7 +511,7 @@ func (x *RenderTagResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RenderTagResponse.ProtoReflect.Descriptor instead.
|
||||
func (*RenderTagResponse) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{6}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{7}
|
||||
}
|
||||
|
||||
func (x *RenderTagResponse) GetHtml() string {
|
||||
@ -454,7 +544,7 @@ type ApplyFilterRequest struct {
|
||||
|
||||
func (x *ApplyFilterRequest) Reset() {
|
||||
*x = ApplyFilterRequest{}
|
||||
mi := &file_v1_render_proto_msgTypes[7]
|
||||
mi := &file_v1_render_proto_msgTypes[8]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -466,7 +556,7 @@ func (x *ApplyFilterRequest) String() string {
|
||||
func (*ApplyFilterRequest) ProtoMessage() {}
|
||||
|
||||
func (x *ApplyFilterRequest) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[7]
|
||||
mi := &file_v1_render_proto_msgTypes[8]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -479,7 +569,7 @@ func (x *ApplyFilterRequest) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ApplyFilterRequest.ProtoReflect.Descriptor instead.
|
||||
func (*ApplyFilterRequest) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{7}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
func (x *ApplyFilterRequest) GetFilterName() string {
|
||||
@ -515,7 +605,7 @@ type ApplyFilterResponse struct {
|
||||
|
||||
func (x *ApplyFilterResponse) Reset() {
|
||||
*x = ApplyFilterResponse{}
|
||||
mi := &file_v1_render_proto_msgTypes[8]
|
||||
mi := &file_v1_render_proto_msgTypes[9]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -527,7 +617,7 @@ func (x *ApplyFilterResponse) String() string {
|
||||
func (*ApplyFilterResponse) ProtoMessage() {}
|
||||
|
||||
func (x *ApplyFilterResponse) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[8]
|
||||
mi := &file_v1_render_proto_msgTypes[9]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -540,7 +630,7 @@ func (x *ApplyFilterResponse) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use ApplyFilterResponse.ProtoReflect.Descriptor instead.
|
||||
func (*ApplyFilterResponse) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{8}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{9}
|
||||
}
|
||||
|
||||
func (x *ApplyFilterResponse) GetOutput() string {
|
||||
@ -604,7 +694,7 @@ type RenderContext struct {
|
||||
|
||||
func (x *RenderContext) Reset() {
|
||||
*x = RenderContext{}
|
||||
mi := &file_v1_render_proto_msgTypes[9]
|
||||
mi := &file_v1_render_proto_msgTypes[10]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -616,7 +706,7 @@ func (x *RenderContext) String() string {
|
||||
func (*RenderContext) ProtoMessage() {}
|
||||
|
||||
func (x *RenderContext) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[9]
|
||||
mi := &file_v1_render_proto_msgTypes[10]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -629,7 +719,7 @@ func (x *RenderContext) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RenderContext.ProtoReflect.Descriptor instead.
|
||||
func (*RenderContext) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{9}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{10}
|
||||
}
|
||||
|
||||
func (x *RenderContext) GetRequest() *RequestInfo {
|
||||
@ -778,7 +868,7 @@ type RequestInfo struct {
|
||||
|
||||
func (x *RequestInfo) Reset() {
|
||||
*x = RequestInfo{}
|
||||
mi := &file_v1_render_proto_msgTypes[10]
|
||||
mi := &file_v1_render_proto_msgTypes[11]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -790,7 +880,7 @@ func (x *RequestInfo) String() string {
|
||||
func (*RequestInfo) ProtoMessage() {}
|
||||
|
||||
func (x *RequestInfo) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[10]
|
||||
mi := &file_v1_render_proto_msgTypes[11]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -803,7 +893,7 @@ func (x *RequestInfo) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use RequestInfo.ProtoReflect.Descriptor instead.
|
||||
func (*RequestInfo) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{10}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{11}
|
||||
}
|
||||
|
||||
func (x *RequestInfo) GetMethod() string {
|
||||
@ -890,7 +980,7 @@ type PageContext struct {
|
||||
|
||||
func (x *PageContext) Reset() {
|
||||
*x = PageContext{}
|
||||
mi := &file_v1_render_proto_msgTypes[11]
|
||||
mi := &file_v1_render_proto_msgTypes[12]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -902,7 +992,7 @@ func (x *PageContext) String() string {
|
||||
func (*PageContext) ProtoMessage() {}
|
||||
|
||||
func (x *PageContext) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[11]
|
||||
mi := &file_v1_render_proto_msgTypes[12]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -915,7 +1005,7 @@ func (x *PageContext) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use PageContext.ProtoReflect.Descriptor instead.
|
||||
func (*PageContext) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{11}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{12}
|
||||
}
|
||||
|
||||
func (x *PageContext) GetId() string {
|
||||
@ -971,7 +1061,7 @@ type PostContext struct {
|
||||
|
||||
func (x *PostContext) Reset() {
|
||||
*x = PostContext{}
|
||||
mi := &file_v1_render_proto_msgTypes[12]
|
||||
mi := &file_v1_render_proto_msgTypes[13]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -983,7 +1073,7 @@ func (x *PostContext) String() string {
|
||||
func (*PostContext) ProtoMessage() {}
|
||||
|
||||
func (x *PostContext) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[12]
|
||||
mi := &file_v1_render_proto_msgTypes[13]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -996,7 +1086,7 @@ func (x *PostContext) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use PostContext.ProtoReflect.Descriptor instead.
|
||||
func (*PostContext) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{12}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{13}
|
||||
}
|
||||
|
||||
func (x *PostContext) GetId() string {
|
||||
@ -1076,7 +1166,7 @@ type AuthorContext struct {
|
||||
|
||||
func (x *AuthorContext) Reset() {
|
||||
*x = AuthorContext{}
|
||||
mi := &file_v1_render_proto_msgTypes[13]
|
||||
mi := &file_v1_render_proto_msgTypes[14]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1088,7 +1178,7 @@ func (x *AuthorContext) String() string {
|
||||
func (*AuthorContext) ProtoMessage() {}
|
||||
|
||||
func (x *AuthorContext) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[13]
|
||||
mi := &file_v1_render_proto_msgTypes[14]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1101,7 +1191,7 @@ func (x *AuthorContext) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use AuthorContext.ProtoReflect.Descriptor instead.
|
||||
func (*AuthorContext) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{13}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{14}
|
||||
}
|
||||
|
||||
func (x *AuthorContext) GetId() string {
|
||||
@ -1151,7 +1241,7 @@ type CategoryContext struct {
|
||||
|
||||
func (x *CategoryContext) Reset() {
|
||||
*x = CategoryContext{}
|
||||
mi := &file_v1_render_proto_msgTypes[14]
|
||||
mi := &file_v1_render_proto_msgTypes[15]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1163,7 +1253,7 @@ func (x *CategoryContext) String() string {
|
||||
func (*CategoryContext) ProtoMessage() {}
|
||||
|
||||
func (x *CategoryContext) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[14]
|
||||
mi := &file_v1_render_proto_msgTypes[15]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1176,7 +1266,7 @@ func (x *CategoryContext) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use CategoryContext.ProtoReflect.Descriptor instead.
|
||||
func (*CategoryContext) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{14}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{15}
|
||||
}
|
||||
|
||||
func (x *CategoryContext) GetId() string {
|
||||
@ -1212,7 +1302,7 @@ type MasterPageContext struct {
|
||||
|
||||
func (x *MasterPageContext) Reset() {
|
||||
*x = MasterPageContext{}
|
||||
mi := &file_v1_render_proto_msgTypes[15]
|
||||
mi := &file_v1_render_proto_msgTypes[16]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1224,7 +1314,7 @@ func (x *MasterPageContext) String() string {
|
||||
func (*MasterPageContext) ProtoMessage() {}
|
||||
|
||||
func (x *MasterPageContext) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[15]
|
||||
mi := &file_v1_render_proto_msgTypes[16]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1237,7 +1327,7 @@ func (x *MasterPageContext) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use MasterPageContext.ProtoReflect.Descriptor instead.
|
||||
func (*MasterPageContext) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{15}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{16}
|
||||
}
|
||||
|
||||
func (x *MasterPageContext) GetId() string {
|
||||
@ -1274,7 +1364,7 @@ type HumanProofBanner struct {
|
||||
|
||||
func (x *HumanProofBanner) Reset() {
|
||||
*x = HumanProofBanner{}
|
||||
mi := &file_v1_render_proto_msgTypes[16]
|
||||
mi := &file_v1_render_proto_msgTypes[17]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1286,7 +1376,7 @@ func (x *HumanProofBanner) String() string {
|
||||
func (*HumanProofBanner) ProtoMessage() {}
|
||||
|
||||
func (x *HumanProofBanner) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[16]
|
||||
mi := &file_v1_render_proto_msgTypes[17]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1299,7 +1389,7 @@ func (x *HumanProofBanner) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use HumanProofBanner.ProtoReflect.Descriptor instead.
|
||||
func (*HumanProofBanner) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{16}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{17}
|
||||
}
|
||||
|
||||
func (x *HumanProofBanner) GetActiveTimeMinutes() int32 {
|
||||
@ -1343,7 +1433,7 @@ type DetailRow struct {
|
||||
|
||||
func (x *DetailRow) Reset() {
|
||||
*x = DetailRow{}
|
||||
mi := &file_v1_render_proto_msgTypes[17]
|
||||
mi := &file_v1_render_proto_msgTypes[18]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1355,7 +1445,7 @@ func (x *DetailRow) String() string {
|
||||
func (*DetailRow) ProtoMessage() {}
|
||||
|
||||
func (x *DetailRow) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[17]
|
||||
mi := &file_v1_render_proto_msgTypes[18]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1368,7 +1458,7 @@ func (x *DetailRow) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use DetailRow.ProtoReflect.Descriptor instead.
|
||||
func (*DetailRow) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{17}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{18}
|
||||
}
|
||||
|
||||
func (x *DetailRow) GetTableId() string {
|
||||
@ -1440,7 +1530,7 @@ type BlockContext struct {
|
||||
|
||||
func (x *BlockContext) Reset() {
|
||||
*x = BlockContext{}
|
||||
mi := &file_v1_render_proto_msgTypes[18]
|
||||
mi := &file_v1_render_proto_msgTypes[19]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@ -1452,7 +1542,7 @@ func (x *BlockContext) String() string {
|
||||
func (*BlockContext) ProtoMessage() {}
|
||||
|
||||
func (x *BlockContext) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_v1_render_proto_msgTypes[18]
|
||||
mi := &file_v1_render_proto_msgTypes[19]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@ -1465,7 +1555,7 @@ func (x *BlockContext) ProtoReflect() protoreflect.Message {
|
||||
|
||||
// Deprecated: Use BlockContext.ProtoReflect.Descriptor instead.
|
||||
func (*BlockContext) Descriptor() ([]byte, []int) {
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{18}
|
||||
return file_v1_render_proto_rawDescGZIP(), []int{19}
|
||||
}
|
||||
|
||||
func (x *BlockContext) GetUrl() string {
|
||||
@ -1752,9 +1842,21 @@ const file_v1_render_proto_rawDesc = "" +
|
||||
"\x15RenderTemplateRequest\x12!\n" +
|
||||
"\ftemplate_key\x18\x01 \x01(\tR\vtemplateKey\x12\x19\n" +
|
||||
"\bdoc_json\x18\x02 \x01(\fR\adocJson\x12<\n" +
|
||||
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\",\n" +
|
||||
"\x16RenderTemplateResponse\x12\x12\n" +
|
||||
"\x04html\x18\x01 \x01(\fR\x04html\"\x88\x01\n" +
|
||||
"\x0erender_context\x18\x03 \x01(\v2\x15.abi.v1.RenderContextR\rrenderContext\"T\n" +
|
||||
"\x16RenderTemplateResponse\x124\n" +
|
||||
"\bdocument\x18\x02 \x01(\v2\x18.abi.v1.TemplateDocumentR\bdocumentJ\x04\b\x01\x10\x02\"\xbf\x02\n" +
|
||||
"\x10TemplateDocument\x12\x1b\n" +
|
||||
"\tbody_html\x18\x01 \x01(\fR\bbodyHtml\x12\x1d\n" +
|
||||
"\n" +
|
||||
"body_class\x18\x02 \x01(\tR\tbodyClass\x12\x12\n" +
|
||||
"\x04lang\x18\x03 \x01(\tR\x04lang\x12&\n" +
|
||||
"\x0fhead_extra_html\x18\x04 \x01(\fR\rheadExtraHtml\x12-\n" +
|
||||
"\x13body_end_extra_html\x18\x05 \x01(\fR\x10bodyEndExtraHtml\x12F\n" +
|
||||
"\n" +
|
||||
"html_attrs\x18\x06 \x03(\v2'.abi.v1.TemplateDocument.HtmlAttrsEntryR\thtmlAttrs\x1a<\n" +
|
||||
"\x0eHtmlAttrsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x88\x01\n" +
|
||||
"\x10RenderTagRequest\x12\x19\n" +
|
||||
"\btag_name\x18\x01 \x01(\tR\atagName\x12\x1b\n" +
|
||||
"\targs_json\x18\x02 \x01(\fR\bargsJson\x12<\n" +
|
||||
@ -1923,62 +2025,66 @@ func file_v1_render_proto_rawDescGZIP() []byte {
|
||||
return file_v1_render_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 25)
|
||||
var file_v1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 27)
|
||||
var file_v1_render_proto_goTypes = []any{
|
||||
(*RenderBlockRequest)(nil), // 0: abi.v1.RenderBlockRequest
|
||||
(*RenderBlockResponse)(nil), // 1: abi.v1.RenderBlockResponse
|
||||
(*PoweredBlock)(nil), // 2: abi.v1.PoweredBlock
|
||||
(*RenderTemplateRequest)(nil), // 3: abi.v1.RenderTemplateRequest
|
||||
(*RenderTemplateResponse)(nil), // 4: abi.v1.RenderTemplateResponse
|
||||
(*RenderTagRequest)(nil), // 5: abi.v1.RenderTagRequest
|
||||
(*RenderTagResponse)(nil), // 6: abi.v1.RenderTagResponse
|
||||
(*ApplyFilterRequest)(nil), // 7: abi.v1.ApplyFilterRequest
|
||||
(*ApplyFilterResponse)(nil), // 8: abi.v1.ApplyFilterResponse
|
||||
(*RenderContext)(nil), // 9: abi.v1.RenderContext
|
||||
(*RequestInfo)(nil), // 10: abi.v1.RequestInfo
|
||||
(*PageContext)(nil), // 11: abi.v1.PageContext
|
||||
(*PostContext)(nil), // 12: abi.v1.PostContext
|
||||
(*AuthorContext)(nil), // 13: abi.v1.AuthorContext
|
||||
(*CategoryContext)(nil), // 14: abi.v1.CategoryContext
|
||||
(*MasterPageContext)(nil), // 15: abi.v1.MasterPageContext
|
||||
(*HumanProofBanner)(nil), // 16: abi.v1.HumanProofBanner
|
||||
(*DetailRow)(nil), // 17: abi.v1.DetailRow
|
||||
(*BlockContext)(nil), // 18: abi.v1.BlockContext
|
||||
nil, // 19: abi.v1.RenderContext.InjectedSlotsEntry
|
||||
nil, // 20: abi.v1.RequestInfo.HeadersEntry
|
||||
nil, // 21: abi.v1.RequestInfo.CookiesEntry
|
||||
nil, // 22: abi.v1.BlockContext.QueryEntry
|
||||
nil, // 23: abi.v1.BlockContext.CookiesEntry
|
||||
nil, // 24: abi.v1.BlockContext.HeadersEntry
|
||||
(*timestamppb.Timestamp)(nil), // 25: google.protobuf.Timestamp
|
||||
(*TemplateDocument)(nil), // 5: abi.v1.TemplateDocument
|
||||
(*RenderTagRequest)(nil), // 6: abi.v1.RenderTagRequest
|
||||
(*RenderTagResponse)(nil), // 7: abi.v1.RenderTagResponse
|
||||
(*ApplyFilterRequest)(nil), // 8: abi.v1.ApplyFilterRequest
|
||||
(*ApplyFilterResponse)(nil), // 9: abi.v1.ApplyFilterResponse
|
||||
(*RenderContext)(nil), // 10: abi.v1.RenderContext
|
||||
(*RequestInfo)(nil), // 11: abi.v1.RequestInfo
|
||||
(*PageContext)(nil), // 12: abi.v1.PageContext
|
||||
(*PostContext)(nil), // 13: abi.v1.PostContext
|
||||
(*AuthorContext)(nil), // 14: abi.v1.AuthorContext
|
||||
(*CategoryContext)(nil), // 15: abi.v1.CategoryContext
|
||||
(*MasterPageContext)(nil), // 16: abi.v1.MasterPageContext
|
||||
(*HumanProofBanner)(nil), // 17: abi.v1.HumanProofBanner
|
||||
(*DetailRow)(nil), // 18: abi.v1.DetailRow
|
||||
(*BlockContext)(nil), // 19: abi.v1.BlockContext
|
||||
nil, // 20: abi.v1.TemplateDocument.HtmlAttrsEntry
|
||||
nil, // 21: abi.v1.RenderContext.InjectedSlotsEntry
|
||||
nil, // 22: abi.v1.RequestInfo.HeadersEntry
|
||||
nil, // 23: abi.v1.RequestInfo.CookiesEntry
|
||||
nil, // 24: abi.v1.BlockContext.QueryEntry
|
||||
nil, // 25: abi.v1.BlockContext.CookiesEntry
|
||||
nil, // 26: abi.v1.BlockContext.HeadersEntry
|
||||
(*timestamppb.Timestamp)(nil), // 27: google.protobuf.Timestamp
|
||||
}
|
||||
var file_v1_render_proto_depIdxs = []int32{
|
||||
9, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
|
||||
10, // 0: abi.v1.RenderBlockRequest.render_context:type_name -> abi.v1.RenderContext
|
||||
2, // 1: abi.v1.RenderBlockResponse.powered:type_name -> abi.v1.PoweredBlock
|
||||
9, // 2: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
|
||||
9, // 3: abi.v1.RenderTagRequest.render_context:type_name -> abi.v1.RenderContext
|
||||
10, // 4: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
|
||||
18, // 5: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
|
||||
11, // 6: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
|
||||
12, // 7: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
|
||||
13, // 8: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
|
||||
14, // 9: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
|
||||
15, // 10: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
|
||||
19, // 11: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
|
||||
16, // 12: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
|
||||
17, // 13: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
|
||||
20, // 14: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
|
||||
21, // 15: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
|
||||
25, // 16: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
|
||||
25, // 17: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
|
||||
22, // 18: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
|
||||
23, // 19: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
|
||||
24, // 20: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
|
||||
21, // [21:21] is the sub-list for method output_type
|
||||
21, // [21:21] is the sub-list for method input_type
|
||||
21, // [21:21] is the sub-list for extension type_name
|
||||
21, // [21:21] is the sub-list for extension extendee
|
||||
0, // [0:21] is the sub-list for field type_name
|
||||
10, // 2: abi.v1.RenderTemplateRequest.render_context:type_name -> abi.v1.RenderContext
|
||||
5, // 3: abi.v1.RenderTemplateResponse.document:type_name -> abi.v1.TemplateDocument
|
||||
20, // 4: abi.v1.TemplateDocument.html_attrs:type_name -> abi.v1.TemplateDocument.HtmlAttrsEntry
|
||||
10, // 5: abi.v1.RenderTagRequest.render_context:type_name -> abi.v1.RenderContext
|
||||
11, // 6: abi.v1.RenderContext.request:type_name -> abi.v1.RequestInfo
|
||||
19, // 7: abi.v1.RenderContext.block_context:type_name -> abi.v1.BlockContext
|
||||
12, // 8: abi.v1.RenderContext.page:type_name -> abi.v1.PageContext
|
||||
13, // 9: abi.v1.RenderContext.post:type_name -> abi.v1.PostContext
|
||||
14, // 10: abi.v1.RenderContext.author:type_name -> abi.v1.AuthorContext
|
||||
15, // 11: abi.v1.RenderContext.category:type_name -> abi.v1.CategoryContext
|
||||
16, // 12: abi.v1.RenderContext.master_page:type_name -> abi.v1.MasterPageContext
|
||||
21, // 13: abi.v1.RenderContext.injected_slots:type_name -> abi.v1.RenderContext.InjectedSlotsEntry
|
||||
17, // 14: abi.v1.RenderContext.human_proof_banner:type_name -> abi.v1.HumanProofBanner
|
||||
18, // 15: abi.v1.RenderContext.detail_row:type_name -> abi.v1.DetailRow
|
||||
22, // 16: abi.v1.RequestInfo.headers:type_name -> abi.v1.RequestInfo.HeadersEntry
|
||||
23, // 17: abi.v1.RequestInfo.cookies:type_name -> abi.v1.RequestInfo.CookiesEntry
|
||||
27, // 18: abi.v1.PostContext.published_at:type_name -> google.protobuf.Timestamp
|
||||
27, // 19: abi.v1.BlockContext.now:type_name -> google.protobuf.Timestamp
|
||||
24, // 20: abi.v1.BlockContext.query:type_name -> abi.v1.BlockContext.QueryEntry
|
||||
25, // 21: abi.v1.BlockContext.cookies:type_name -> abi.v1.BlockContext.CookiesEntry
|
||||
26, // 22: abi.v1.BlockContext.headers:type_name -> abi.v1.BlockContext.HeadersEntry
|
||||
23, // [23:23] is the sub-list for method output_type
|
||||
23, // [23:23] is the sub-list for method input_type
|
||||
23, // [23:23] is the sub-list for extension type_name
|
||||
23, // [23:23] is the sub-list for extension extendee
|
||||
0, // [0:23] is the sub-list for field type_name
|
||||
}
|
||||
|
||||
func init() { file_v1_render_proto_init() }
|
||||
@ -1992,7 +2098,7 @@ func file_v1_render_proto_init() {
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_render_proto_rawDesc), len(file_v1_render_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 25,
|
||||
NumMessages: 27,
|
||||
NumExtensions: 0,
|
||||
NumServices: 0,
|
||||
},
|
||||
|
||||
@ -36,6 +36,13 @@ const (
|
||||
HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id"
|
||||
HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username"
|
||||
HeaderVerifiedPublicEmail = "X-Bn-Verified-Public-Email"
|
||||
|
||||
// Captcha proof. The host verifies (and consumes — replay-protected) the
|
||||
// request's cap-token against the instance captcha server before dispatch
|
||||
// and stamps "1" on success. A guest cannot hold the host's stateful
|
||||
// verifier, so this header is the ONLY way to enforce captcha on a plugin
|
||||
// HTTP endpoint; render the widget with the ninjatpl captcha tag.
|
||||
HeaderVerifiedCaptcha = "X-Bn-Verified-Captcha"
|
||||
)
|
||||
|
||||
// AllTrustedHeaders lists every trusted identity header, canonical form. The
|
||||
@ -49,9 +56,16 @@ func AllTrustedHeaders() []string {
|
||||
HeaderVerifiedPublicUserID,
|
||||
HeaderVerifiedPublicUsername,
|
||||
HeaderVerifiedPublicEmail,
|
||||
HeaderVerifiedCaptcha,
|
||||
}
|
||||
}
|
||||
|
||||
// CaptchaVerified reports whether the host verified a captcha token for this
|
||||
// request. Fail closed: enforcing handlers must reject when this is false.
|
||||
func CaptchaVerified(h http.Header) bool {
|
||||
return h.Get(HeaderVerifiedCaptcha) == "1"
|
||||
}
|
||||
|
||||
// ContextFromTrustedHeaders rebuilds the request's auth context from the host's
|
||||
// verified identity headers. Trust model: see the const block above — these
|
||||
// headers are host-controlled, so no token parsing or signature check happens
|
||||
|
||||
@ -80,3 +80,27 @@ func TestTrustedHeaderMiddleware(t *testing.T) {
|
||||
t.Fatalf("middleware did not install principal: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaVerifiedReadsTrustedHeader(t *testing.T) {
|
||||
h := http.Header{}
|
||||
if CaptchaVerified(h) {
|
||||
t.Fatal("CaptchaVerified() = true with no header, want false")
|
||||
}
|
||||
h.Set(HeaderVerifiedCaptcha, "1")
|
||||
if !CaptchaVerified(h) {
|
||||
t.Fatal("CaptchaVerified() = false with host-stamped header, want true")
|
||||
}
|
||||
h.Set(HeaderVerifiedCaptcha, "true")
|
||||
if CaptchaVerified(h) {
|
||||
t.Fatal(`CaptchaVerified() = true for non-"1" value, want false`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllTrustedHeadersIncludesCaptcha(t *testing.T) {
|
||||
for _, h := range AllTrustedHeaders() {
|
||||
if h == HeaderVerifiedCaptcha {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("AllTrustedHeaders() missing %s — hosts strip-then-stamp from this list, so omitting it makes the header client-forgeable", HeaderVerifiedCaptcha)
|
||||
}
|
||||
|
||||
@ -13,6 +13,17 @@ type MediaValue struct {
|
||||
Filename string // Original filename
|
||||
Width int // Image width in pixels
|
||||
Height int // Image height in pixels
|
||||
// Attribution is the stock-source credit (Pexels import), nil for
|
||||
// ordinary uploads. JSON tags mirror the CMS media row's attribution JSONB.
|
||||
Attribution *MediaAttribution
|
||||
}
|
||||
|
||||
// MediaAttribution is the stock-source credit for imported media.
|
||||
type MediaAttribution struct {
|
||||
Source string `json:"source"`
|
||||
SourceURL string `json:"source_url"`
|
||||
Photographer string `json:"photographer"`
|
||||
PhotographerURL string `json:"photographer_url"`
|
||||
}
|
||||
|
||||
// String renders the default <img> tag representation.
|
||||
|
||||
@ -59,6 +59,12 @@ type ListPostsParams struct {
|
||||
type Content interface {
|
||||
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
|
||||
GetPage(ctx context.Context, slug string) (*PageInfo, error)
|
||||
// PublishedBlockConfigs returns the stored content (config) of every block
|
||||
// with the given key inside a currently published page snapshot. This is
|
||||
// the server-authoritative source for per-block settings a sandboxed guest
|
||||
// cannot query itself (e.g. whether any published booking block demands
|
||||
// captcha) — never trust the same flags from the client request.
|
||||
PublishedBlockConfigs(ctx context.Context, blockKey string) ([]map[string]any, error)
|
||||
GetPost(ctx context.Context, slug string) (*PostInfo, error)
|
||||
ListPosts(ctx context.Context, params ListPostsParams) ([]PostInfo, error)
|
||||
Slugify(text string) string
|
||||
|
||||
46
datatables/datatables.go
Normal file
46
datatables/datatables.go
Normal file
@ -0,0 +1,46 @@
|
||||
// Package datatables gives plugins typed access to site data tables (the Data
|
||||
// Platform's data_tables/data_table_rows), which the per-plugin Postgres role
|
||||
// deliberately cannot reach with raw SQL. The CMS implements this interface
|
||||
// host-side and wires it into CoreServices; row payloads cross the ABI as JSON.
|
||||
package datatables
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// DataTables provides read/write access to site data tables for plugins.
|
||||
type DataTables interface {
|
||||
// GetTableByKey resolves a data table by its stable key (e.g. "playgrounds").
|
||||
GetTableByKey(ctx context.Context, tableKey string) (*Table, error)
|
||||
// GetRow fetches a single row by ID.
|
||||
GetRow(ctx context.Context, rowID uuid.UUID) (*Row, error)
|
||||
// ListRows pages through a table's rows in sort order. limit <= 0 applies
|
||||
// the host default.
|
||||
ListRows(ctx context.Context, tableID uuid.UUID, limit, offset int32) ([]Row, error)
|
||||
// CreateRow appends a row with the given JSON object payload.
|
||||
CreateRow(ctx context.Context, tableID uuid.UUID, dataJSON []byte) (*Row, error)
|
||||
// UpdateRowData replaces a row's JSON payload, preserving its sort order.
|
||||
UpdateRowData(ctx context.Context, rowID uuid.UUID, dataJSON []byte) (*Row, error)
|
||||
// FindRowByField returns the first row whose data->>field equals value, or
|
||||
// (nil, nil) when no row matches.
|
||||
FindRowByField(ctx context.Context, tableID uuid.UUID, field, value string) (*Row, error)
|
||||
}
|
||||
|
||||
// Table is a plugin-facing view of a data table.
|
||||
type Table struct {
|
||||
ID uuid.UUID
|
||||
TableKey string
|
||||
Name string
|
||||
RowCount int32
|
||||
}
|
||||
|
||||
// Row is a plugin-facing view of a data table row. DataJSON is the row's data
|
||||
// column verbatim (a JSON object).
|
||||
type Row struct {
|
||||
ID uuid.UUID
|
||||
TableID uuid.UUID
|
||||
DataJSON []byte
|
||||
SortOrder int32
|
||||
}
|
||||
183
egress/egress.go
Normal file
183
egress/egress.go
Normal file
@ -0,0 +1,183 @@
|
||||
// Package egress defines the wire-independent policy for host-mediated plugin
|
||||
// HTTP egress (ADR 0023, cms repo): the host-pattern grammar and matching, the
|
||||
// default resource bounds, and the ErrDenied sentinel. Both the guest SDK
|
||||
// (validating plugin.mod at build) and the cms host (enforcing grants at
|
||||
// fetch) import this package so the pattern semantics have exactly one
|
||||
// definition.
|
||||
package egress
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
||||
// ErrDenied marks an egress request refused by policy — host not granted,
|
||||
// grant revoked, platform-denylisted, or an off-allowlist redirect. It is
|
||||
// deliberately distinct from a network failure: a plugin can catch it and
|
||||
// degrade gracefully. It maps to ABI_ERROR_CODE_EGRESS_DENIED across the wire.
|
||||
var ErrDenied = errors.New("egress denied")
|
||||
|
||||
const (
|
||||
// DefaultMaxResponseBytes is the per-request response cap when a plugin
|
||||
// declares no larger one. Oversize fails the request, never truncates.
|
||||
DefaultMaxResponseBytes = 10 << 20
|
||||
// MaxRequestBytes caps the request body a guest may marshal across the
|
||||
// host_call boundary.
|
||||
MaxRequestBytes = 10 << 20
|
||||
// DefaultPort is the port a host pattern authorizes when it names none.
|
||||
DefaultPort = 443
|
||||
)
|
||||
|
||||
// Pattern is one parsed allowed_hosts entry: an exact host or a left-anchored
|
||||
// multi-label wildcard, optionally pinned to a non-default port.
|
||||
type Pattern struct {
|
||||
// host is the bare hostname (wildcard: the base domain after "*.").
|
||||
host string
|
||||
wildcard bool
|
||||
port int
|
||||
}
|
||||
|
||||
// ParsePattern parses and validates one allowed_hosts entry. It rejects bare
|
||||
// "*", public-suffix wildcards ("*.com", "*.co.uk"), embedded schemes/paths,
|
||||
// and malformed ports. A leading "*." marks a multi-label wildcard whose base
|
||||
// must be a registrable domain (≥2 labels and not itself a public suffix).
|
||||
func ParsePattern(raw string) (Pattern, error) {
|
||||
s := strings.TrimSpace(strings.ToLower(raw))
|
||||
if s == "" {
|
||||
return Pattern{}, fmt.Errorf("empty host pattern")
|
||||
}
|
||||
if strings.ContainsAny(s, "/:@") && !strings.Contains(s, ":") {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q must be a bare host, not a URL", raw)
|
||||
}
|
||||
if strings.Contains(s, "://") || strings.ContainsAny(s, "/@") {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q must be a bare host, not a URL", raw)
|
||||
}
|
||||
|
||||
port := DefaultPort
|
||||
host := s
|
||||
if h, p, ok := splitHostPort(s); ok {
|
||||
host = h
|
||||
parsed, err := strconv.Atoi(p)
|
||||
if err != nil || parsed < 1 || parsed > 65535 {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q has an invalid port", raw)
|
||||
}
|
||||
port = parsed
|
||||
}
|
||||
|
||||
wildcard := false
|
||||
if strings.HasPrefix(host, "*.") {
|
||||
wildcard = true
|
||||
host = strings.TrimPrefix(host, "*.")
|
||||
if host == "" || strings.Contains(host, "*") {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q: only a single leading %q wildcard is allowed", raw, "*.")
|
||||
}
|
||||
if strings.Count(host, ".") < 1 {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q: wildcard base must be a registrable domain (e.g. *.cal.com)", raw)
|
||||
}
|
||||
if suffix, _ := publicsuffix.PublicSuffix(host); suffix == host {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q: cannot wildcard a public suffix", raw)
|
||||
}
|
||||
}
|
||||
if host == "" || strings.Contains(host, "*") {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q is malformed", raw)
|
||||
}
|
||||
if !validHostname(host) {
|
||||
return Pattern{}, fmt.Errorf("host pattern %q is not a valid hostname", raw)
|
||||
}
|
||||
return Pattern{host: host, wildcard: wildcard, port: port}, nil
|
||||
}
|
||||
|
||||
// matches reports whether this pattern authorizes host:port.
|
||||
func (p Pattern) matches(host string, port int) bool {
|
||||
if port != p.port {
|
||||
return false
|
||||
}
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
if p.wildcard {
|
||||
return strings.HasSuffix(host, "."+p.host)
|
||||
}
|
||||
return host == p.host
|
||||
}
|
||||
|
||||
// Allowlist is a plugin's parsed egress grant.
|
||||
type Allowlist []Pattern
|
||||
|
||||
// ParseAllowlist parses every entry, failing on the first invalid one.
|
||||
func ParseAllowlist(raw []string) (Allowlist, error) {
|
||||
out := make(Allowlist, 0, len(raw))
|
||||
for _, r := range raw {
|
||||
p, err := ParsePattern(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Allows returns nil if u is authorized by this allowlist, else an ErrDenied.
|
||||
// It enforces the https-only scheme rule and the port pinning; the caller
|
||||
// still applies the SSRF IP guard and platform denylist.
|
||||
func (a Allowlist) Allows(u *url.URL) error {
|
||||
if u == nil {
|
||||
return fmt.Errorf("%w: nil URL", ErrDenied)
|
||||
}
|
||||
if strings.ToLower(u.Scheme) != "https" {
|
||||
return fmt.Errorf("%w: %q is not https", ErrDenied, u.Scheme)
|
||||
}
|
||||
host := u.Hostname()
|
||||
if host == "" {
|
||||
return fmt.Errorf("%w: missing host", ErrDenied)
|
||||
}
|
||||
port := DefaultPort
|
||||
if p := u.Port(); p != "" {
|
||||
parsed, err := strconv.Atoi(p)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: invalid port", ErrDenied)
|
||||
}
|
||||
port = parsed
|
||||
}
|
||||
for _, pat := range a {
|
||||
if pat.matches(host, port) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: host %q not in allowlist", ErrDenied, u.Host)
|
||||
}
|
||||
|
||||
func splitHostPort(s string) (host, port string, ok bool) {
|
||||
i := strings.LastIndex(s, ":")
|
||||
if i < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
// Reject bracketed IPv6 / multiple colons — patterns are hostnames.
|
||||
if strings.Contains(s[:i], ":") {
|
||||
return "", "", false
|
||||
}
|
||||
return s[:i], s[i+1:], true
|
||||
}
|
||||
|
||||
func validHostname(h string) bool {
|
||||
if len(h) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, label := range strings.Split(h, ".") {
|
||||
if label == "" || len(label) > 63 {
|
||||
return false
|
||||
}
|
||||
for _, r := range label {
|
||||
if !(r >= 'a' && r <= 'z') && !(r >= '0' && r <= '9') && r != '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if label[0] == '-' || label[len(label)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
67
egress/egress_test.go
Normal file
67
egress/egress_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package egress
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePatternRejects(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"", "*", "*.com", "*.co.uk", "*.uk",
|
||||
"https://api.cal.com", "api.cal.com/path", "user@api.cal.com",
|
||||
"*.*.cal.com", "api.cal.com:0", "api.cal.com:99999",
|
||||
"*.", "-bad.com", "bad-.com",
|
||||
} {
|
||||
if _, err := ParsePattern(raw); err == nil {
|
||||
t.Errorf("ParsePattern(%q) = nil error, want rejection", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePatternAccepts(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"api.cal.com", "*.cal.com", "example.com",
|
||||
"api.example.com:8443", "sub.deep.example.org",
|
||||
} {
|
||||
if _, err := ParsePattern(raw); err != nil {
|
||||
t.Errorf("ParsePattern(%q) = %v, want accept", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllowlistAllows(t *testing.T) {
|
||||
al, err := ParseAllowlist([]string{"api.cal.com", "*.example.org", "api.svc.com:8443"})
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAllowlist: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
url string
|
||||
allow bool
|
||||
}{
|
||||
{"https://api.cal.com/v1/slots", true},
|
||||
{"https://api.cal.com:443/v1/slots", true},
|
||||
{"https://cal.com/", false}, // apex not matched by exact api.cal.com
|
||||
{"http://api.cal.com/", false}, // http refused
|
||||
{"https://api.cal.com:8443/", false}, // wrong port
|
||||
{"https://a.example.org/", true}, // wildcard depth 1
|
||||
{"https://a.b.c.example.org/", true}, // wildcard multi-label
|
||||
{"https://example.org/", false}, // wildcard excludes apex
|
||||
{"https://evilexample.org/", false}, // suffix must be dot-anchored
|
||||
{"https://api.svc.com:8443/", true}, // explicit port
|
||||
{"https://api.svc.com/", false}, // default port not authorized
|
||||
}
|
||||
for _, tt := range tests {
|
||||
u, _ := url.Parse(tt.url)
|
||||
err := al.Allows(u)
|
||||
if tt.allow && err != nil {
|
||||
t.Errorf("Allows(%q) = %v, want allow", tt.url, err)
|
||||
}
|
||||
if !tt.allow && err == nil {
|
||||
t.Errorf("Allows(%q) = nil, want deny", tt.url)
|
||||
}
|
||||
if !tt.allow && err != nil && !errors.Is(err, ErrDenied) {
|
||||
t.Errorf("Allows(%q) denial not ErrDenied: %v", tt.url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
6
go.mod
6
go.mod
@ -4,19 +4,19 @@ 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/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/tetratelabs/wazero v1.12.0
|
||||
golang.org/x/mod v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/text v0.38.0 // indirect
|
||||
)
|
||||
|
||||
16
go.sum
16
go.sum
@ -1,7 +1,5 @@
|
||||
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=
|
||||
@ -32,12 +30,14 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||
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=
|
||||
|
||||
@ -2,6 +2,7 @@ package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"connectrpc.com/connect"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
||||
@ -9,6 +10,7 @@ import (
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/datasources"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/datatables"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/gating"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/menus"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
||||
@ -26,6 +28,7 @@ type CoreServices struct {
|
||||
Crypto crypto.Crypto
|
||||
Menus menus.Menus
|
||||
Datasources datasources.Datasources
|
||||
DataTables datatables.DataTables
|
||||
PublicUsers auth.PublicUsers
|
||||
Subscriptions subscriptions.Subscriptions
|
||||
|
||||
@ -52,6 +55,13 @@ type CoreServices struct {
|
||||
// Plugin interop
|
||||
Bridge PluginBridge
|
||||
|
||||
// OutboundHTTP is the host-mediated egress transport (ADR 0023, cms repo).
|
||||
// Plugins make outbound requests through &http.Client{Transport:
|
||||
// deps.OutboundHTTP}; the host enforces the plugin's allowed_hosts grant,
|
||||
// the SSRF guard, and the platform denylist. Nil when the plugin declared
|
||||
// no allowed_hosts — a request through it fails closed.
|
||||
OutboundHTTP http.RoundTripper
|
||||
|
||||
// Core RPC services — pre-built bindings for CMS-provided services
|
||||
CoreServiceBindings CoreServiceBindings
|
||||
ReviewSubmitter ReviewSubmitter
|
||||
|
||||
@ -53,6 +53,40 @@ type ModPlugin struct {
|
||||
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
|
||||
// straight from plugin.mod as a fallback. OFF by default.
|
||||
DataDir bool `toml:"data_dir,omitempty"`
|
||||
// AllowedHosts declares the egress host patterns the plugin needs for
|
||||
// http.request (ADR 0023, cms repo): an exact hostname or a left-anchored
|
||||
// multi-label wildcard (e.g. "*.cal.com"), each https/443 unless the entry
|
||||
// names an explicit port. First-class for the same reason as DataDir — the
|
||||
// writeMod round-trip drops any key without a struct home. `ninja plugin
|
||||
// build` validates the patterns and stamps them into the manifest
|
||||
// (PluginManifest.allowed_hosts). Empty means no egress.
|
||||
AllowedHosts []string `toml:"allowed_hosts,omitempty"`
|
||||
// MaxResponseMB requests a larger per-request http.request response cap
|
||||
// than the 10 MB default. Rides the same admin grant as AllowedHosts. Zero
|
||||
// means the default.
|
||||
MaxResponseMB uint32 `toml:"max_response_mb,omitempty"`
|
||||
// PublicRoutes declares site-root paths the plugin serves through its
|
||||
// HTTP handler (ADR 0026, cms repo): exact paths and path prefixes the
|
||||
// host mounts at the public site root. First-class for the same reason as
|
||||
// DataDir and AllowedHosts: the writeMod round-trip drops any key without
|
||||
// a struct home, and a dropped route claim silently unmounts live SEO
|
||||
// URLs on the next publish. `ninja plugin build` validates the claims
|
||||
// (ValidatePublicRoutes) and stamps them into the manifest
|
||||
// (PluginManifest.public_routes). Empty means the plugin serves HTTP only
|
||||
// under /api/plugins/<name>.
|
||||
PublicRoutes []ModPublicRoute `toml:"public_routes,omitempty"`
|
||||
// Sitemap requests sitemap contribution: when true the host fetches
|
||||
// entries from the plugin's well-known sitemap endpoint and merges them
|
||||
// into the site sitemap. Stamped into the manifest alongside
|
||||
// PublicRoutes; requires an HTTP handler.
|
||||
Sitemap bool `toml:"sitemap,omitempty"`
|
||||
}
|
||||
|
||||
// ModPublicRoute is one site-root path claim in plugin.mod, e.g.
|
||||
// { path = "/area", prefix = true }.
|
||||
type ModPublicRoute struct {
|
||||
Path string `toml:"path"`
|
||||
Prefix bool `toml:"prefix,omitempty"`
|
||||
}
|
||||
|
||||
type ModCompat struct {
|
||||
|
||||
90
plugin/public_routes.go
Normal file
90
plugin/public_routes.go
Normal file
@ -0,0 +1,90 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxPublicRoutes caps the number of site-root claims a single plugin may
|
||||
// declare. Generous: real plugins claim a handful of SEO prefixes.
|
||||
const MaxPublicRoutes = 64
|
||||
|
||||
// ReservedRoutePrefixes are site-root prefixes plugins may never claim: the
|
||||
// admin SPA, the namespaced plugin HTTP mount, and the realtime websocket.
|
||||
// A claim equal to or under one of these is rejected, as is a prefix claim
|
||||
// that would cover one (e.g. prefix "/api" covers "/api/plugins").
|
||||
var ReservedRoutePrefixes = []string{"/admin", "/api/plugins", "/ws"}
|
||||
|
||||
// routeSegmentRe matches one path segment: URL-safe unreserved characters.
|
||||
var routeSegmentRe = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
|
||||
|
||||
// ValidatePublicRoutes checks a plugin.mod public_routes declaration. It
|
||||
// returns an error listing every offending entry so authors fix them in one
|
||||
// pass.
|
||||
//
|
||||
// Rules:
|
||||
// - at most MaxPublicRoutes entries
|
||||
// - each path is absolute ("/..."), not the site root "/" itself
|
||||
// - segments are non-empty (no "//", no trailing "/"), never "." or "..",
|
||||
// and use URL-safe unreserved characters only
|
||||
// - no claim equal to or under a reserved prefix (ReservedRoutePrefixes),
|
||||
// and no prefix claim that covers a reserved prefix
|
||||
// - no duplicate paths
|
||||
func ValidatePublicRoutes(routes []ModPublicRoute) error {
|
||||
if len(routes) > MaxPublicRoutes {
|
||||
return fmt.Errorf("too many public_routes: got %d, max %d", len(routes), MaxPublicRoutes)
|
||||
}
|
||||
seen := make(map[string]struct{}, len(routes))
|
||||
var bad []string
|
||||
for _, r := range routes {
|
||||
if reason := checkRoutePath(r); reason != "" {
|
||||
bad = append(bad, fmt.Sprintf("%q (%s)", r.Path, reason))
|
||||
continue
|
||||
}
|
||||
if _, dup := seen[r.Path]; dup {
|
||||
bad = append(bad, fmt.Sprintf("%q (duplicate path)", r.Path))
|
||||
continue
|
||||
}
|
||||
seen[r.Path] = struct{}{}
|
||||
}
|
||||
if len(bad) > 0 {
|
||||
return fmt.Errorf("invalid public_routes: %s", strings.Join(bad, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRoutePath(r ModPublicRoute) string {
|
||||
p := r.Path
|
||||
if p == "" {
|
||||
return "empty path"
|
||||
}
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
return "path must be absolute, starting with /"
|
||||
}
|
||||
if p == "/" {
|
||||
return "cannot claim the site root"
|
||||
}
|
||||
if strings.HasSuffix(p, "/") {
|
||||
return "no trailing slash; set prefix = true to claim the subtree"
|
||||
}
|
||||
for _, seg := range strings.Split(p[1:], "/") {
|
||||
switch {
|
||||
case seg == "":
|
||||
return "empty path segment"
|
||||
case seg == "." || seg == "..":
|
||||
return "path traversal segment"
|
||||
case !routeSegmentRe.MatchString(seg):
|
||||
return fmt.Sprintf("segment %q has characters outside [A-Za-z0-9._~-]", seg)
|
||||
}
|
||||
}
|
||||
for _, reserved := range ReservedRoutePrefixes {
|
||||
if p == reserved || strings.HasPrefix(p, reserved+"/") {
|
||||
return fmt.Sprintf("reserved prefix %s", reserved)
|
||||
}
|
||||
if r.Prefix && strings.HasPrefix(reserved, p+"/") {
|
||||
return fmt.Sprintf("prefix claim covers reserved prefix %s", reserved)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
214
plugin/public_routes_test.go
Normal file
214
plugin/public_routes_test.go
Normal file
@ -0,0 +1,214 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseModFull_PublicRoutesAndSitemap(t *testing.T) {
|
||||
src := []byte(`
|
||||
[plugin]
|
||||
name = "perthplaygrounds"
|
||||
version = "1.0.0"
|
||||
public_routes = [{ path = "/area", prefix = true }, { path = "/badges" }, { path = "/api/directory", prefix = true }]
|
||||
sitemap = true
|
||||
`)
|
||||
m, err := ParseModFull(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModFull err: %v", err)
|
||||
}
|
||||
routes := m.Plugin.PublicRoutes
|
||||
if len(routes) != 3 {
|
||||
t.Fatalf("PublicRoutes len = %d, want 3 (%v)", len(routes), routes)
|
||||
}
|
||||
want := []ModPublicRoute{
|
||||
{Path: "/area", Prefix: true},
|
||||
{Path: "/badges", Prefix: false},
|
||||
{Path: "/api/directory", Prefix: true},
|
||||
}
|
||||
for i, w := range want {
|
||||
if routes[i] != w {
|
||||
t.Errorf("PublicRoutes[%d] = %+v, want %+v", i, routes[i], w)
|
||||
}
|
||||
}
|
||||
if !m.Plugin.Sitemap {
|
||||
t.Errorf("Sitemap = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModFull_PublicRoutesArrayOfTables(t *testing.T) {
|
||||
src := []byte(`
|
||||
[plugin]
|
||||
name = "perthplaygrounds"
|
||||
version = "1.0.0"
|
||||
|
||||
[[plugin.public_routes]]
|
||||
path = "/area"
|
||||
prefix = true
|
||||
|
||||
[[plugin.public_routes]]
|
||||
path = "/badges"
|
||||
`)
|
||||
m, err := ParseModFull(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModFull err: %v", err)
|
||||
}
|
||||
routes := m.Plugin.PublicRoutes
|
||||
if len(routes) != 2 {
|
||||
t.Fatalf("PublicRoutes len = %d, want 2 (%v)", len(routes), routes)
|
||||
}
|
||||
if routes[0] != (ModPublicRoute{Path: "/area", Prefix: true}) {
|
||||
t.Errorf("PublicRoutes[0] = %+v", routes[0])
|
||||
}
|
||||
if routes[1] != (ModPublicRoute{Path: "/badges"}) {
|
||||
t.Errorf("PublicRoutes[1] = %+v", routes[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseModFull_PublicRoutesOmitted(t *testing.T) {
|
||||
src := []byte(`
|
||||
[plugin]
|
||||
name = "plain"
|
||||
version = "0.1.0"
|
||||
`)
|
||||
m, err := ParseModFull(src)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseModFull err: %v", err)
|
||||
}
|
||||
if m.Plugin.PublicRoutes != nil {
|
||||
t.Errorf("PublicRoutes = %v, want nil when omitted", m.Plugin.PublicRoutes)
|
||||
}
|
||||
if m.Plugin.Sitemap {
|
||||
t.Errorf("Sitemap = true, want false (absent key)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_HappyPath(t *testing.T) {
|
||||
err := ValidatePublicRoutes([]ModPublicRoute{
|
||||
{Path: "/area", Prefix: true},
|
||||
{Path: "/badges", Prefix: true},
|
||||
{Path: "/age", Prefix: true},
|
||||
{Path: "/api/directory", Prefix: true},
|
||||
{Path: "/api/semantic-search"},
|
||||
{Path: "/some_page.html"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ValidatePublicRoutes err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_EmptyAndNil(t *testing.T) {
|
||||
if err := ValidatePublicRoutes(nil); err != nil {
|
||||
t.Errorf("nil err: %v", err)
|
||||
}
|
||||
if err := ValidatePublicRoutes([]ModPublicRoute{}); err != nil {
|
||||
t.Errorf("empty err: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_RejectsReservedPrefixes(t *testing.T) {
|
||||
cases := []ModPublicRoute{
|
||||
{Path: "/admin"},
|
||||
{Path: "/admin", Prefix: true},
|
||||
{Path: "/admin/settings"},
|
||||
{Path: "/api/plugins"},
|
||||
{Path: "/api/plugins/foo", Prefix: true},
|
||||
{Path: "/ws"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := ValidatePublicRoutes([]ModPublicRoute{c})
|
||||
if err == nil {
|
||||
t.Errorf("ValidatePublicRoutes(%+v) = nil, want reserved-prefix error", c)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), "reserved prefix") {
|
||||
t.Errorf("error for %+v does not mention reserved prefix: %v", c, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_RejectsPrefixCoveringReserved(t *testing.T) {
|
||||
err := ValidatePublicRoutes([]ModPublicRoute{{Path: "/api", Prefix: true}})
|
||||
if err == nil {
|
||||
t.Fatal("prefix claim /api should be rejected: it covers /api/plugins")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "covers reserved prefix /api/plugins") {
|
||||
t.Errorf("error does not explain the covered prefix: %v", err)
|
||||
}
|
||||
if err := ValidatePublicRoutes([]ModPublicRoute{{Path: "/api"}}); err != nil {
|
||||
t.Errorf("exact claim /api should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_RejectsMalformedPaths(t *testing.T) {
|
||||
cases := []struct {
|
||||
route ModPublicRoute
|
||||
frag string
|
||||
}{
|
||||
{ModPublicRoute{Path: ""}, "empty path"},
|
||||
{ModPublicRoute{Path: "area"}, "absolute"},
|
||||
{ModPublicRoute{Path: "/"}, "site root"},
|
||||
{ModPublicRoute{Path: "/area/"}, "trailing slash"},
|
||||
{ModPublicRoute{Path: "/area//list"}, "empty path segment"},
|
||||
{ModPublicRoute{Path: "/area/../admin"}, "traversal"},
|
||||
{ModPublicRoute{Path: "/area?x=1"}, "characters outside"},
|
||||
{ModPublicRoute{Path: "/area list"}, "characters outside"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
err := ValidatePublicRoutes([]ModPublicRoute{c.route})
|
||||
if err == nil {
|
||||
t.Errorf("ValidatePublicRoutes(%q) = nil, want error", c.route.Path)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.frag) {
|
||||
t.Errorf("error for %q = %v, want mention of %q", c.route.Path, err, c.frag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_RejectsDuplicates(t *testing.T) {
|
||||
err := ValidatePublicRoutes([]ModPublicRoute{
|
||||
{Path: "/area"},
|
||||
{Path: "/area", Prefix: true},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("duplicate path should be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "duplicate path") {
|
||||
t.Errorf("error does not mention duplicate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_ReportsAllOffendersInOnePass(t *testing.T) {
|
||||
err := ValidatePublicRoutes([]ModPublicRoute{
|
||||
{Path: "bad"},
|
||||
{Path: "/admin/x"},
|
||||
{Path: "/fine"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
for _, frag := range []string{`"bad"`, `"/admin/x"`} {
|
||||
if !strings.Contains(err.Error(), frag) {
|
||||
t.Errorf("error %q does not mention %s", err.Error(), frag)
|
||||
}
|
||||
}
|
||||
if strings.Contains(err.Error(), `"/fine"`) {
|
||||
t.Errorf("error should not name the valid route: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatePublicRoutes_CapEnforced(t *testing.T) {
|
||||
routes := make([]ModPublicRoute, MaxPublicRoutes+1)
|
||||
for i := range routes {
|
||||
routes[i] = ModPublicRoute{Path: fmt.Sprintf("/r%d", i)}
|
||||
}
|
||||
err := ValidatePublicRoutes(routes)
|
||||
if err == nil {
|
||||
t.Fatal("expected too-many error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "too many public_routes") {
|
||||
t.Errorf("error %q does not mention the cap", err.Error())
|
||||
}
|
||||
}
|
||||
@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@ -13,6 +15,7 @@ import (
|
||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/egress"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/gating"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||
"github.com/google/uuid"
|
||||
@ -160,6 +163,19 @@ func TestCapabilityRoundTrip(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_published_block_configs", wantMethod: "content.published_block_configs",
|
||||
resp: &abiv1.ContentPublishedBlockConfigsResponse{Configs: [][]byte{[]byte(`{"captchaEnabled":true,"eventTypeSlug":"30min","username":"alice"}`)}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.Content.PublishedBlockConfigs(ctx, "calcom:booking")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0]["username"] != "alice" || got[0]["captchaEnabled"] != true {
|
||||
t.Errorf("configs = %+v", got)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "content_get_page", wantMethod: "content.get_page",
|
||||
resp: &abiv1.ContentGetPageResponse{Page: &abiv1.PageInfo{Id: idAuthor.String(), Slug: "about", Title: "About Us"}},
|
||||
@ -389,6 +405,90 @@ func TestCapabilityRoundTrip(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- datatables ---
|
||||
{
|
||||
name: "datatables_get_table_by_key", wantMethod: "datatables.get_table_by_key",
|
||||
resp: &abiv1.DatatablesGetTableByKeyResponse{Table: &abiv1.DataTableInfo{
|
||||
Id: idTable.String(), TableKey: "playgrounds", Name: "Playgrounds", RowCount: 678,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.GetTableByKey(ctx, "playgrounds")
|
||||
if err != nil || got.ID != idTable || got.TableKey != "playgrounds" || got.RowCount != 678 {
|
||||
t.Errorf("table = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_get_row", wantMethod: "datatables.get_row",
|
||||
resp: &abiv1.DatatablesGetRowResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Whiteman Park"}`), SortOrder: 3,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.GetRow(ctx, idRow)
|
||||
if err != nil || got.ID != idRow || got.TableID != idTable || got.SortOrder != 3 {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_list_rows", wantMethod: "datatables.list_rows",
|
||||
resp: &abiv1.DatatablesListRowsResponse{Rows: []*abiv1.DataTableRowInfo{
|
||||
{Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"A"}`)},
|
||||
{Id: idItem.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"B"}`), SortOrder: 1},
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.ListRows(ctx, idTable, 100, 0)
|
||||
if err != nil || len(got) != 2 || got[0].ID != idRow || got[1].SortOrder != 1 {
|
||||
t.Errorf("rows = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_create_row", wantMethod: "datatables.create_row",
|
||||
resp: &abiv1.DatatablesCreateRowResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"New"}`),
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.CreateRow(ctx, idTable, []byte(`{"name":"New"}`))
|
||||
if err != nil || got.ID != idRow || string(got.DataJSON) != `{"name":"New"}` {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_update_row_data", wantMethod: "datatables.update_row_data",
|
||||
resp: &abiv1.DatatablesUpdateRowDataResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"name":"Updated"}`), SortOrder: 7,
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.UpdateRowData(ctx, idRow, []byte(`{"name":"Updated"}`))
|
||||
if err != nil || got.SortOrder != 7 || string(got.DataJSON) != `{"name":"Updated"}` {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_find_row_by_field", wantMethod: "datatables.find_row_by_field",
|
||||
resp: &abiv1.DatatablesFindRowByFieldResponse{Row: &abiv1.DataTableRowInfo{
|
||||
Id: idRow.String(), TableId: idTable.String(), DataJson: []byte(`{"google_place_id":"abc"}`),
|
||||
}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "abc")
|
||||
if err != nil || got == nil || got.ID != idRow {
|
||||
t.Errorf("row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "datatables_find_row_by_field_miss", wantMethod: "datatables.find_row_by_field",
|
||||
resp: &abiv1.DatatablesFindRowByFieldResponse{},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
got, err := cs.DataTables.FindRowByField(ctx, idTable, "google_place_id", "missing")
|
||||
if err != nil || got != nil {
|
||||
t.Errorf("want nil, nil on miss; row = %+v err = %v", got, err)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- users ---
|
||||
{
|
||||
name: "users_get_by_username", wantMethod: "users.get_by_username",
|
||||
@ -851,6 +951,35 @@ func TestCapabilityRoundTrip(t *testing.T) {
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- http.request (ADR 0023; RoundTripper, not a value-returning stub) ---
|
||||
{
|
||||
name: "http_request", wantMethod: "http.request",
|
||||
resp: &abiv1.HttpRequestResponse{
|
||||
Status: 200,
|
||||
Headers: map[string]*abiv1.HeaderValues{"Content-Type": {Values: []string{"application/json"}}},
|
||||
Body: []byte(`{"ok":true}`),
|
||||
FinalUrl: "https://api.cal.com/v1/slots",
|
||||
},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
req, err := http.NewRequest(http.MethodGet, "https://api.cal.com/v1/slots", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer test")
|
||||
res, err := cs.OutboundHTTP.RoundTrip(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = res.Body.Close() }()
|
||||
if res.StatusCode != 200 || res.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("response = %+v", res)
|
||||
}
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
if string(body) != `{"ok":true}` {
|
||||
t.Errorf("body = %q", body)
|
||||
}
|
||||
},
|
||||
},
|
||||
// --- jobs.progress (WO-WZ-019; sent by the job dispatch path, not a
|
||||
// CoreServices stub, so the case drives the transport directly) ---
|
||||
{
|
||||
@ -919,6 +1048,22 @@ func TestErrorMappingDeadlineBecomesContextDeadline(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEgressDeniedMapsToErrDenied(t *testing.T) {
|
||||
f := &fakeTransport{t: t, err: &fakeAbiErr{code: abiv1.AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED, msg: `host "evil.example" not in allowlist`}}
|
||||
cs := NewCoreServices(f.call)
|
||||
f.name = "http_request" // reuse the request golden for the compare
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://api.cal.com/v1/slots", nil)
|
||||
req.Header.Set("Authorization", "Bearer test") // match the http_request_req golden
|
||||
_, err := cs.OutboundHTTP.RoundTrip(req)
|
||||
if err == nil {
|
||||
t.Fatal("want error")
|
||||
}
|
||||
if !errors.Is(err, egress.ErrDenied) {
|
||||
t.Errorf("error %q does not wrap egress.ErrDenied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilTransportFailsCleanly(t *testing.T) {
|
||||
cs := NewCoreServices(nil)
|
||||
_, err := cs.Content.GetPage(context.Background(), "about")
|
||||
|
||||
@ -47,6 +47,22 @@ func (s *contentStub) GetPage(ctx context.Context, slug string) (*content.PageIn
|
||||
return &content.PageInfo{ID: parseUUID(p.GetId()), Slug: p.GetSlug(), Title: p.GetTitle()}, nil
|
||||
}
|
||||
|
||||
func (s *contentStub) PublishedBlockConfigs(ctx context.Context, blockKey string) ([]map[string]any, error) {
|
||||
resp := &abiv1.ContentPublishedBlockConfigsResponse{}
|
||||
if err := s.invoke(ctx, "published_block_configs", &abiv1.ContentPublishedBlockConfigsRequest{BlockKey: blockKey}, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
configs := make([]map[string]any, 0, len(resp.GetConfigs()))
|
||||
for _, raw := range resp.GetConfigs() {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
continue
|
||||
}
|
||||
configs = append(configs, m)
|
||||
}
|
||||
return configs, nil
|
||||
}
|
||||
|
||||
func (s *contentStub) GetPost(ctx context.Context, slug string) (*content.PostInfo, error) {
|
||||
resp := &abiv1.ContentGetPostResponse{}
|
||||
if err := s.invoke(ctx, "get_post", &abiv1.ContentGetPostRequest{Slug: slug}, resp); err != nil {
|
||||
|
||||
@ -34,6 +34,7 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
|
||||
Crypto: &cryptoStub{base{family: "crypto", call: call}},
|
||||
Menus: &menusStub{base{family: "menus", call: call}},
|
||||
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
|
||||
DataTables: &datatablesStub{base{family: "datatables", call: call}},
|
||||
PublicUsers: &usersStub{base{family: "users", call: call}},
|
||||
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
|
||||
Media: &mediaStub{base{family: "media", call: call}},
|
||||
@ -47,5 +48,6 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
|
||||
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
|
||||
RAGService: NewRAGStub(call),
|
||||
Provisioner: &provisionerStub{base{family: "provisioner", call: call}},
|
||||
OutboundHTTP: &httpStub{base{family: "http", call: call}},
|
||||
}
|
||||
}
|
||||
|
||||
102
plugin/wasmguest/caps/datatables.go
Normal file
102
plugin/wasmguest/caps/datatables.go
Normal file
@ -0,0 +1,102 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/datatables"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// datatablesStub implements datatables.DataTables over datatables.* capability
|
||||
// calls. Row payloads cross as JSON bytes.
|
||||
type datatablesStub struct{ base }
|
||||
|
||||
var _ datatables.DataTables = (*datatablesStub)(nil)
|
||||
|
||||
func (s *datatablesStub) GetTableByKey(ctx context.Context, tableKey string) (*datatables.Table, error) {
|
||||
resp := &abiv1.DatatablesGetTableByKeyResponse{}
|
||||
req := &abiv1.DatatablesGetTableByKeyRequest{TableKey: tableKey}
|
||||
if err := s.invoke(ctx, "get_table_by_key", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableFromProto(resp.GetTable()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) GetRow(ctx context.Context, rowID uuid.UUID) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesGetRowResponse{}
|
||||
req := &abiv1.DatatablesGetRowRequest{RowId: rowID.String()}
|
||||
if err := s.invoke(ctx, "get_row", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) ListRows(ctx context.Context, tableID uuid.UUID, limit, offset int32) ([]datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesListRowsResponse{}
|
||||
req := &abiv1.DatatablesListRowsRequest{TableId: tableID.String(), Limit: limit, Offset: offset}
|
||||
if err := s.invoke(ctx, "list_rows", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := make([]datatables.Row, 0, len(resp.GetRows()))
|
||||
for _, r := range resp.GetRows() {
|
||||
if row := dataTableRowFromProto(r); row != nil {
|
||||
rows = append(rows, *row)
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) CreateRow(ctx context.Context, tableID uuid.UUID, dataJSON []byte) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesCreateRowResponse{}
|
||||
req := &abiv1.DatatablesCreateRowRequest{TableId: tableID.String(), DataJson: dataJSON}
|
||||
if err := s.invoke(ctx, "create_row", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) UpdateRowData(ctx context.Context, rowID uuid.UUID, dataJSON []byte) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesUpdateRowDataResponse{}
|
||||
req := &abiv1.DatatablesUpdateRowDataRequest{RowId: rowID.String(), DataJson: dataJSON}
|
||||
if err := s.invoke(ctx, "update_row_data", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func (s *datatablesStub) FindRowByField(ctx context.Context, tableID uuid.UUID, field, value string) (*datatables.Row, error) {
|
||||
resp := &abiv1.DatatablesFindRowByFieldResponse{}
|
||||
req := &abiv1.DatatablesFindRowByFieldRequest{TableId: tableID.String(), Field: field, Value: value}
|
||||
if err := s.invoke(ctx, "find_row_by_field", req, resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dataTableRowFromProto(resp.GetRow()), nil
|
||||
}
|
||||
|
||||
func dataTableFromProto(t *abiv1.DataTableInfo) *datatables.Table {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
id, _ := uuid.Parse(t.GetId())
|
||||
return &datatables.Table{
|
||||
ID: id,
|
||||
TableKey: t.GetTableKey(),
|
||||
Name: t.GetName(),
|
||||
RowCount: t.GetRowCount(),
|
||||
}
|
||||
}
|
||||
|
||||
func dataTableRowFromProto(r *abiv1.DataTableRowInfo) *datatables.Row {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
id, _ := uuid.Parse(r.GetId())
|
||||
tableID, _ := uuid.Parse(r.GetTableId())
|
||||
return &datatables.Row{
|
||||
ID: id,
|
||||
TableID: tableID,
|
||||
DataJSON: r.GetDataJson(),
|
||||
SortOrder: r.GetSortOrder(),
|
||||
}
|
||||
}
|
||||
87
plugin/wasmguest/caps/net.go
Normal file
87
plugin/wasmguest/caps/net.go
Normal file
@ -0,0 +1,87 @@
|
||||
package caps
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/egress"
|
||||
)
|
||||
|
||||
// httpStub implements http.RoundTripper over the http.request capability: it
|
||||
// marshals the outbound *http.Request into an HttpRequestRequest, hands it to
|
||||
// the host (which enforces the plugin's egress grant, the SSRF guard, and the
|
||||
// platform denylist, then performs the request), and rebuilds the reply into
|
||||
// an *http.Response. The guest never touches a socket. Plugins use it through
|
||||
// an ordinary &http.Client{Transport: deps.OutboundHTTP}.
|
||||
type httpStub struct{ base }
|
||||
|
||||
var _ http.RoundTripper = (*httpStub)(nil)
|
||||
|
||||
// MaxRequestBytes bounds the request body the guest will marshal across the
|
||||
// boundary, matching the host's cap; larger fails before any host call.
|
||||
func (s *httpStub) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
var body []byte
|
||||
if req.Body != nil {
|
||||
defer func() { _ = req.Body.Close() }()
|
||||
b, err := io.ReadAll(io.LimitReader(req.Body, egress.MaxRequestBytes+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("http.request: read body: %w", err)
|
||||
}
|
||||
if len(b) > egress.MaxRequestBytes {
|
||||
return nil, fmt.Errorf("http.request: request body exceeds %d bytes", egress.MaxRequestBytes)
|
||||
}
|
||||
body = b
|
||||
}
|
||||
|
||||
out := &abiv1.HttpRequestRequest{
|
||||
Method: req.Method,
|
||||
Url: req.URL.String(),
|
||||
Headers: make(map[string]*abiv1.HeaderValues, len(req.Header)),
|
||||
Body: body,
|
||||
}
|
||||
for name, values := range req.Header {
|
||||
out.Headers[name] = &abiv1.HeaderValues{Values: values}
|
||||
}
|
||||
if dl, ok := req.Context().Deadline(); ok {
|
||||
if ms := time.Until(dl).Milliseconds(); ms > 0 {
|
||||
out.TimeoutMs = uint32(ms)
|
||||
}
|
||||
}
|
||||
|
||||
resp := &abiv1.HttpRequestResponse{}
|
||||
if err := s.invoke(req.Context(), "request", out, resp); err != nil {
|
||||
if isEgressDenied(err) {
|
||||
return nil, fmt.Errorf("%w: %s", egress.ErrDenied, err.Error())
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header := make(http.Header, len(resp.GetHeaders()))
|
||||
for name, hv := range resp.GetHeaders() {
|
||||
for _, v := range hv.GetValues() {
|
||||
header.Add(name, v)
|
||||
}
|
||||
}
|
||||
return &http.Response{
|
||||
Status: http.StatusText(int(resp.GetStatus())),
|
||||
StatusCode: int(resp.GetStatus()),
|
||||
Proto: "HTTP/1.1",
|
||||
ProtoMajor: 1,
|
||||
ProtoMinor: 1,
|
||||
Header: header,
|
||||
Body: io.NopCloser(bytes.NewReader(resp.GetBody())),
|
||||
ContentLength: int64(len(resp.GetBody())),
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isEgressDenied(err error) bool {
|
||||
var coded abiCoded
|
||||
return errors.As(err, &coded) &&
|
||||
coded.AbiErrorCode() == abiv1.AbiErrorCode_ABI_ERROR_CODE_EGRESS_DENIED
|
||||
}
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_published_block_configs_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_published_block_configs_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
calcom:booking
|
||||
2
plugin/wasmguest/caps/testdata/golden/content_published_block_configs_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/content_published_block_configs_resp.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
B{"captchaEnabled":true,"eventTypeSlug":"30min","username":"alice"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_create_row_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_create_row_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_create_row_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_create_row_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
\
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idmissing
|
||||
0
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_resp.pb
vendored
Normal file
0
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_miss_resp.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idabc
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_find_row_by_field_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
g
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"google_place_id":"abc"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_get_row_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_get_row_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_get_row_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_get_row_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
h
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Whiteman Park"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
playgrounds
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_get_table_by_key_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
C
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaaplaygroundsPlaygrounds ¦
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaad
|
||||
5
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_resp.pb
vendored
Normal file
5
plugin/wasmguest/caps/testdata/golden/datatables_list_rows_resp.pb
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
|
||||
Z
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"A"}
|
||||
\
|
||||
$44444444-4444-4444-4444-444444444444$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"B"}
|
||||
2
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_req.pb
vendored
Normal file
2
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_req.pb
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb{"name":"Updated"}
|
||||
3
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/datatables_update_row_data_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
|
||||
b
|
||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Updated"}
|
||||
4
plugin/wasmguest/caps/testdata/golden/http_request_req.pb
vendored
Normal file
4
plugin/wasmguest/caps/testdata/golden/http_request_req.pb
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
|
||||
GEThttps://api.cal.com/v1/slots
|
||||
Authorization
|
||||
Bearer test
|
||||
3
plugin/wasmguest/caps/testdata/golden/http_request_resp.pb
vendored
Normal file
3
plugin/wasmguest/caps/testdata/golden/http_request_resp.pb
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
È"
|
||||
Content-Type
|
||||
application/json{"ok":true}"https://api.cal.com/v1/slots
|
||||
@ -17,6 +17,7 @@ import (
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/bnwasm"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/plugin/wasmguest/caps"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/templates"
|
||||
"github.com/google/uuid"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
@ -331,11 +332,44 @@ func (g *guest) renderTemplate(ctx context.Context, payload []byte) (proto.Messa
|
||||
if component == nil {
|
||||
return nil, internalError("template " + req.GetTemplateKey() + " returned nil component")
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := component.Render(ctx, &buf); err != nil {
|
||||
return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
|
||||
tdoc := &abiv1.TemplateDocument{}
|
||||
renderPart := func(c templates.HTMLComponent) ([]byte, error) {
|
||||
if c == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := c.Render(ctx, &buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
return &abiv1.RenderTemplateResponse{Html: buf.Bytes()}, nil
|
||||
if pd, ok := component.(*templates.PageDocument); ok {
|
||||
body, err := renderPart(pd.Body)
|
||||
if err != nil {
|
||||
return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
|
||||
}
|
||||
head, err := renderPart(pd.HeadExtra)
|
||||
if err != nil {
|
||||
return nil, internalError("render template " + req.GetTemplateKey() + " head extra: " + err.Error())
|
||||
}
|
||||
end, err := renderPart(pd.BodyEndExtra)
|
||||
if err != nil {
|
||||
return nil, internalError("render template " + req.GetTemplateKey() + " body-end extra: " + err.Error())
|
||||
}
|
||||
tdoc.BodyHtml = body
|
||||
tdoc.BodyClass = pd.BodyClass
|
||||
tdoc.Lang = pd.Lang
|
||||
tdoc.HtmlAttrs = pd.HTMLAttrs
|
||||
tdoc.HeadExtraHtml = head
|
||||
tdoc.BodyEndExtraHtml = end
|
||||
} else {
|
||||
body, err := renderPart(component)
|
||||
if err != nil {
|
||||
return nil, internalError("render template " + req.GetTemplateKey() + ": " + err.Error())
|
||||
}
|
||||
tdoc.BodyHtml = body
|
||||
}
|
||||
return &abiv1.RenderTemplateResponse{Document: tdoc}, nil
|
||||
}
|
||||
|
||||
func (g *guest) handleHTTP(ctx context.Context, payload []byte) (proto.Message, *abiv1.AbiError) {
|
||||
|
||||
@ -334,8 +334,90 @@ func TestRenderTemplate(t *testing.T) {
|
||||
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
if string(rr.GetHtml()) != "<html>My Page</html>" {
|
||||
t.Errorf("html = %q", rr.GetHtml())
|
||||
if string(rr.GetDocument().GetBodyHtml()) != "<html>My Page</html>" {
|
||||
t.Errorf("body_html = %q", rr.GetDocument().GetBodyHtml())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTemplatePlainComponentBecomesBodyOnly(t *testing.T) {
|
||||
g := newGuest(plugin.PluginRegistration{
|
||||
Name: "tpltest",
|
||||
Register: func(tr templates.TemplateRegistry, _ blocks.BlockRegistry) error {
|
||||
return tr.Register("plain", func(_ context.Context, _ map[string]any) templates.HTMLComponent {
|
||||
return textComponent("<h1>hi</h1>")
|
||||
})
|
||||
},
|
||||
})
|
||||
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
|
||||
TemplateKey: "plain",
|
||||
})
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("render error: %v", resp.GetError())
|
||||
}
|
||||
rr := &abiv1.RenderTemplateResponse{}
|
||||
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
doc := rr.GetDocument()
|
||||
if doc == nil {
|
||||
t.Fatal("nil document")
|
||||
}
|
||||
if string(doc.GetBodyHtml()) != "<h1>hi</h1>" {
|
||||
t.Errorf("body_html = %q", doc.GetBodyHtml())
|
||||
}
|
||||
if doc.GetBodyClass() != "" || doc.GetLang() != "" || len(doc.GetHtmlAttrs()) != 0 ||
|
||||
len(doc.GetHeadExtraHtml()) != 0 || len(doc.GetBodyEndExtraHtml()) != 0 {
|
||||
t.Errorf("plain component should set only body_html, got %+v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTemplatePageDocumentFillsEnvelope(t *testing.T) {
|
||||
g := newGuest(plugin.PluginRegistration{
|
||||
Name: "tpltest",
|
||||
Register: func(tr templates.TemplateRegistry, _ blocks.BlockRegistry) error {
|
||||
return tr.Register("page", func(_ context.Context, _ map[string]any) templates.HTMLComponent {
|
||||
return &templates.PageDocument{
|
||||
Body: textComponent("<main>body</main>"),
|
||||
BodyClass: "cls",
|
||||
Lang: "fr",
|
||||
HTMLAttrs: map[string]string{"data-theme": "x"},
|
||||
HeadExtra: textComponent(`<meta name="e">`),
|
||||
BodyEndExtra: textComponent("<script>1</script>"),
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
resp := invokeHook(t, g, abiv1.Hook_HOOK_RENDER_TEMPLATE, &abiv1.RenderTemplateRequest{
|
||||
TemplateKey: "page",
|
||||
})
|
||||
if resp.GetError() != nil {
|
||||
t.Fatalf("render error: %v", resp.GetError())
|
||||
}
|
||||
rr := &abiv1.RenderTemplateResponse{}
|
||||
if err := proto.Unmarshal(resp.GetPayload(), rr); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
doc := rr.GetDocument()
|
||||
if doc == nil {
|
||||
t.Fatal("nil document")
|
||||
}
|
||||
if string(doc.GetBodyHtml()) != "<main>body</main>" {
|
||||
t.Errorf("body_html = %q", doc.GetBodyHtml())
|
||||
}
|
||||
if doc.GetBodyClass() != "cls" {
|
||||
t.Errorf("body_class = %q", doc.GetBodyClass())
|
||||
}
|
||||
if doc.GetLang() != "fr" {
|
||||
t.Errorf("lang = %q", doc.GetLang())
|
||||
}
|
||||
if doc.GetHtmlAttrs()["data-theme"] != "x" {
|
||||
t.Errorf("html_attrs = %v", doc.GetHtmlAttrs())
|
||||
}
|
||||
if string(doc.GetHeadExtraHtml()) != `<meta name="e">` {
|
||||
t.Errorf("head_extra_html = %q", doc.GetHeadExtraHtml())
|
||||
}
|
||||
if string(doc.GetBodyEndExtraHtml()) != "<script>1</script>" {
|
||||
t.Errorf("body_end_extra_html = %q", doc.GetBodyEndExtraHtml())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -183,8 +183,8 @@ func TestWasmFixtureDescribeRoundTrip(t *testing.T) {
|
||||
if err := proto.Unmarshal(resp.GetPayload(), rt); err != nil {
|
||||
t.Fatalf("unmarshal RenderTemplateResponse: %v", err)
|
||||
}
|
||||
if string(rt.GetHtml()) != "<!doctype html><title>Wazero</title>" {
|
||||
t.Errorf("template html = %q", rt.GetHtml())
|
||||
if string(rt.GetDocument().GetBodyHtml()) != "<!doctype html><title>Wazero</title>" {
|
||||
t.Errorf("template body_html = %q", rt.GetDocument().GetBodyHtml())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
769
render/blocknote.go
Normal file
769
render/blocknote.go
Normal file
@ -0,0 +1,769 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"strings"
|
||||
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML.
|
||||
func BlockNoteToHTML(ctx context.Context, doc map[string]any) string {
|
||||
rawBlocks := blocksFromRaw(doc["blocks"])
|
||||
if len(rawBlocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return renderBlocks(ctx, rawBlocks)
|
||||
}
|
||||
|
||||
func renderBlocks(ctx context.Context, blocks []map[string]any) string {
|
||||
var sb strings.Builder
|
||||
var currentListType string
|
||||
var listItems []map[string]any
|
||||
|
||||
flushList := func() {
|
||||
if len(listItems) == 0 {
|
||||
return
|
||||
}
|
||||
tag := "ul"
|
||||
listStyle := "list-disc"
|
||||
if currentListType == "numberedListItem" {
|
||||
tag = "ol"
|
||||
listStyle = "list-decimal"
|
||||
}
|
||||
fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle)
|
||||
for _, item := range listItems {
|
||||
content := inlineContentFromRaw(item["content"])
|
||||
childrenHTML := renderChildren(ctx, item["children"])
|
||||
sb.WriteString("<li>")
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
if childrenHTML != "" {
|
||||
sb.WriteString(childrenHTML)
|
||||
}
|
||||
sb.WriteString("</li>\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "</%s>\n", tag)
|
||||
listItems = nil
|
||||
currentListType = ""
|
||||
}
|
||||
|
||||
for _, blockMap := range blocks {
|
||||
blockType, _ := blockMap["type"].(string)
|
||||
|
||||
if blockType == "bulletListItem" || blockType == "numberedListItem" {
|
||||
if currentListType != "" && currentListType != blockType {
|
||||
flushList()
|
||||
}
|
||||
currentListType = blockType
|
||||
listItems = append(listItems, blockMap)
|
||||
continue
|
||||
}
|
||||
|
||||
flushList()
|
||||
sb.WriteString(renderBlock(ctx, blockMap))
|
||||
}
|
||||
|
||||
flushList()
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func inlineContentFromRaw(raw any) []map[string]any {
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
items := make([]map[string]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
items = append(items, m)
|
||||
}
|
||||
}
|
||||
return items
|
||||
case []map[string]any:
|
||||
return v
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return []map[string]any{{"type": "text", "text": v}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func blocksFromRaw(raw any) []map[string]any {
|
||||
switch v := raw.(type) {
|
||||
case []any:
|
||||
items := make([]map[string]any, 0, len(v))
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]any); ok {
|
||||
items = append(items, m)
|
||||
}
|
||||
}
|
||||
return items
|
||||
case []map[string]any:
|
||||
return v
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func textAlignClass(props map[string]any) string {
|
||||
if props == nil {
|
||||
return ""
|
||||
}
|
||||
align, _ := props["textAlignment"].(string)
|
||||
switch align {
|
||||
case "left":
|
||||
return "text-left"
|
||||
case "center":
|
||||
return "text-center"
|
||||
case "right":
|
||||
return "text-right"
|
||||
case "justify":
|
||||
return "text-justify"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func safeClassToken(value string) string {
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
continue
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func isHexDigit(c rune) bool {
|
||||
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
||||
}
|
||||
|
||||
func colorValueToClass(value string, prefix string) string {
|
||||
if value == "" || value == "default" {
|
||||
return ""
|
||||
}
|
||||
|
||||
if after, ok := strings.CutPrefix(value, "hex:"); ok {
|
||||
hex := after
|
||||
if len(hex) != 7 && len(hex) != 4 {
|
||||
return ""
|
||||
}
|
||||
for i, r := range hex {
|
||||
if i == 0 && r == '#' {
|
||||
continue
|
||||
}
|
||||
if !isHexDigit(r) {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s-[%s]", prefix, hex)
|
||||
}
|
||||
|
||||
if after, ok := strings.CutPrefix(value, "custom:"); ok {
|
||||
name := safeClassToken(after)
|
||||
if name == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s-[hsl(var(--color-%s))]", prefix, name)
|
||||
}
|
||||
|
||||
value = safeClassToken(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s-%s", prefix, value)
|
||||
}
|
||||
|
||||
func renderBlock(ctx context.Context, block map[string]any) string {
|
||||
blockType, _ := block["type"].(string)
|
||||
props, _ := block["props"].(map[string]any)
|
||||
content := inlineContentFromRaw(block["content"])
|
||||
childrenHTML := renderChildren(ctx, block["children"])
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
switch blockType {
|
||||
case "paragraph":
|
||||
classNames := "my-4"
|
||||
if alignClass := textAlignClass(props); alignClass != "" {
|
||||
classNames += " " + alignClass
|
||||
}
|
||||
fmt.Fprintf(&sb, "<p class=\"%s\">", classNames)
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
if childrenHTML != "" {
|
||||
sb.WriteString(childrenHTML)
|
||||
}
|
||||
sb.WriteString("</p>\n")
|
||||
|
||||
case "heading":
|
||||
level := 2
|
||||
if l, ok := props["level"].(float64); ok {
|
||||
level = int(l)
|
||||
} else if l, ok := props["level"].(int); ok {
|
||||
level = l
|
||||
}
|
||||
if level < 1 {
|
||||
level = 1
|
||||
}
|
||||
if level > 6 {
|
||||
level = 6
|
||||
}
|
||||
classNames := "mt-8 mb-4 font-bold"
|
||||
if alignClass := textAlignClass(props); alignClass != "" {
|
||||
classNames += " " + alignClass
|
||||
}
|
||||
fmt.Fprintf(&sb, "<h%d class=\"%s\">", level, classNames)
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
if childrenHTML != "" {
|
||||
sb.WriteString(childrenHTML)
|
||||
}
|
||||
fmt.Fprintf(&sb, "</h%d>\n", level)
|
||||
|
||||
case "quote":
|
||||
classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground"
|
||||
if alignClass := textAlignClass(props); alignClass != "" {
|
||||
classNames += " " + alignClass
|
||||
}
|
||||
fmt.Fprintf(&sb, "<blockquote class=\"%s\">", classNames)
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
if childrenHTML != "" {
|
||||
sb.WriteString(childrenHTML)
|
||||
}
|
||||
sb.WriteString("</blockquote>\n")
|
||||
|
||||
case "checkListItem":
|
||||
checked := false
|
||||
if c, ok := props["checked"].(bool); ok {
|
||||
checked = c
|
||||
}
|
||||
checkedAttr := ""
|
||||
if checked {
|
||||
checkedAttr = " checked"
|
||||
}
|
||||
fmt.Fprintf(&sb, `<div class="check-list-item my-2 flex items-start gap-2"><input type="checkbox" disabled%s><span>`, checkedAttr)
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
sb.WriteString("</span>")
|
||||
if childrenHTML != "" {
|
||||
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
|
||||
}
|
||||
sb.WriteString("</div>\n")
|
||||
|
||||
case "toggleListItem":
|
||||
openAttr := ""
|
||||
if open, ok := props["open"].(bool); ok && open {
|
||||
openAttr = " open"
|
||||
}
|
||||
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
|
||||
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
sb.WriteString("</summary>")
|
||||
if childrenHTML != "" {
|
||||
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
|
||||
}
|
||||
sb.WriteString("</details>\n")
|
||||
|
||||
case "codeBlock":
|
||||
lang := ""
|
||||
if l, ok := props["language"].(string); ok {
|
||||
lang = l
|
||||
}
|
||||
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
sb.WriteString("</code></pre>\n")
|
||||
|
||||
case "image":
|
||||
url := ""
|
||||
caption := ""
|
||||
alt := ""
|
||||
if u, ok := props["url"].(string); ok {
|
||||
url = u
|
||||
}
|
||||
if c, ok := props["caption"].(string); ok {
|
||||
caption = c
|
||||
}
|
||||
if a, ok := props["alt"].(string); ok {
|
||||
alt = a
|
||||
}
|
||||
if alt == "" {
|
||||
alt = caption
|
||||
}
|
||||
link := ""
|
||||
if l, ok := props["link"].(string); ok {
|
||||
link = l
|
||||
}
|
||||
width := ""
|
||||
if w, ok := props["width"].(string); ok {
|
||||
width = w
|
||||
}
|
||||
align := ""
|
||||
if a, ok := props["align"].(string); ok {
|
||||
align = a
|
||||
}
|
||||
attMode := ""
|
||||
if m, ok := props["attributionMode"].(string); ok {
|
||||
attMode = m
|
||||
}
|
||||
|
||||
var att *blocks.MediaAttribution
|
||||
if attMode == "chip" || attMode == "chipHover" || attMode == "below" {
|
||||
if idStr, ok := props["mediaId"].(string); ok && idStr != "" {
|
||||
if id, err := uuid.Parse(idStr); err == nil {
|
||||
if resolver := blocks.GetMediaResolver(ctx); resolver != nil {
|
||||
if mv := resolver.ResolveMedia(ctx, id); mv != nil {
|
||||
att = mv.Attribution
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
chip := ""
|
||||
switch attMode {
|
||||
case "chip":
|
||||
chip = buildAttributionHTML(att, imageChipClass)
|
||||
case "chipHover":
|
||||
chip = buildAttributionHTML(att, imageChipClass+" opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity")
|
||||
}
|
||||
|
||||
figureClass := "my-6"
|
||||
switch width {
|
||||
case "small":
|
||||
figureClass += " max-w-[400px]"
|
||||
case "medium":
|
||||
figureClass += " max-w-[600px]"
|
||||
case "wide":
|
||||
figureClass += " max-w-[800px]"
|
||||
}
|
||||
if width == "small" || width == "medium" || width == "wide" {
|
||||
switch align {
|
||||
case "left":
|
||||
figureClass += " float-left mr-6 mb-4"
|
||||
case "right":
|
||||
figureClass += " float-right ml-6 mb-4"
|
||||
default:
|
||||
figureClass += " mx-auto"
|
||||
}
|
||||
}
|
||||
|
||||
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
|
||||
if link != "" {
|
||||
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
|
||||
}
|
||||
// The positioning wrapper only exists when a chip is rendered, so
|
||||
// legacy and chip-less blocks keep today's exact markup.
|
||||
if chip != "" {
|
||||
wrapClass := "relative overflow-hidden"
|
||||
if attMode == "chipHover" {
|
||||
wrapClass = "group " + wrapClass
|
||||
}
|
||||
img = fmt.Sprintf(`<div class="%s">%s%s</div>`, wrapClass, img, chip)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, `<figure class="%s">`, figureClass)
|
||||
sb.WriteString(img)
|
||||
if caption != "" {
|
||||
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
||||
}
|
||||
if attMode == "below" {
|
||||
if credit := buildAttributionHTML(att, ""); credit != "" {
|
||||
fmt.Fprintf(&sb, `<div class="mt-1 text-xs text-muted-foreground text-center">%s</div>`, credit)
|
||||
}
|
||||
}
|
||||
sb.WriteString("</figure>\n")
|
||||
|
||||
case "video":
|
||||
url := ""
|
||||
caption := ""
|
||||
if u, ok := props["url"].(string); ok {
|
||||
url = u
|
||||
}
|
||||
if c, ok := props["caption"].(string); ok {
|
||||
caption = c
|
||||
}
|
||||
sb.WriteString(`<figure class="my-6">`)
|
||||
fmt.Fprintf(&sb, `<video src="%s" controls></video>`, html.EscapeString(url))
|
||||
if caption != "" {
|
||||
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
||||
}
|
||||
sb.WriteString("</figure>\n")
|
||||
|
||||
case "audio":
|
||||
url := ""
|
||||
caption := ""
|
||||
if u, ok := props["url"].(string); ok {
|
||||
url = u
|
||||
}
|
||||
if c, ok := props["caption"].(string); ok {
|
||||
caption = c
|
||||
}
|
||||
sb.WriteString(`<figure class="my-6">`)
|
||||
fmt.Fprintf(&sb, `<audio src="%s" controls></audio>`, html.EscapeString(url))
|
||||
if caption != "" {
|
||||
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
||||
}
|
||||
sb.WriteString("</figure>\n")
|
||||
|
||||
case "file":
|
||||
url := ""
|
||||
name := ""
|
||||
caption := ""
|
||||
if u, ok := props["url"].(string); ok {
|
||||
url = u
|
||||
}
|
||||
if n, ok := props["name"].(string); ok {
|
||||
name = n
|
||||
}
|
||||
if c, ok := props["caption"].(string); ok {
|
||||
caption = c
|
||||
}
|
||||
if name == "" {
|
||||
name = url
|
||||
}
|
||||
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
|
||||
if url != "" {
|
||||
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
|
||||
sb.WriteString(html.EscapeString(name))
|
||||
sb.WriteString("</a>")
|
||||
} else {
|
||||
sb.WriteString(html.EscapeString(name))
|
||||
}
|
||||
if caption != "" {
|
||||
fmt.Fprintf(&sb, `<p class="mt-2 text-sm text-muted-foreground">%s</p>`, html.EscapeString(caption))
|
||||
}
|
||||
sb.WriteString("</div>\n")
|
||||
|
||||
case "table":
|
||||
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
|
||||
if tableContent, ok := block["content"].(map[string]any); ok {
|
||||
if rows, ok := tableContent["rows"].([]any); ok && len(rows) > 0 {
|
||||
renderTableRow := func(row any, isHeader bool) {
|
||||
if rowMap, ok := row.(map[string]any); ok {
|
||||
if cells, ok := rowMap["cells"].([]any); ok {
|
||||
for _, cell := range cells {
|
||||
cellTag := "td"
|
||||
cellClass := "px-4 py-2 text-sm"
|
||||
if isHeader {
|
||||
cellTag = "th"
|
||||
cellClass = "px-4 py-3 text-left text-sm font-semibold"
|
||||
}
|
||||
// BlockNote >=0.15 wraps each cell as a tableCell object
|
||||
// {type:"tableCell", content:[...]}; older docs store the
|
||||
// cell's inline content (string or array) directly.
|
||||
cellContent := cell
|
||||
if cellMap, ok := cell.(map[string]any); ok {
|
||||
if t, _ := cellMap["type"].(string); t == "tableCell" {
|
||||
cellContent = cellMap["content"]
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
|
||||
sb.WriteString(renderInlineContent(inlineContentFromRaw(cellContent), false))
|
||||
fmt.Fprintf(&sb, "</%s>", cellTag)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("<thead><tr class=\"bg-muted\">")
|
||||
renderTableRow(rows[0], true)
|
||||
sb.WriteString("</tr></thead>")
|
||||
if len(rows) > 1 {
|
||||
sb.WriteString("<tbody>")
|
||||
for _, row := range rows[1:] {
|
||||
sb.WriteString("<tr class=\"border-b border-border\">")
|
||||
renderTableRow(row, false)
|
||||
sb.WriteString("</tr>")
|
||||
}
|
||||
sb.WriteString("</tbody>")
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("</table></div>\n")
|
||||
|
||||
case "embed":
|
||||
if resolver := blocks.GetEmbedResolver(ctx); resolver != nil {
|
||||
blockID := ""
|
||||
if v, ok := props["blockId"].(string); ok {
|
||||
blockID = v
|
||||
}
|
||||
dataSource := ""
|
||||
if v, ok := props["dataSource"].(string); ok {
|
||||
dataSource = v
|
||||
}
|
||||
layout := "full"
|
||||
if v, ok := props["layout"].(string); ok && v != "" {
|
||||
layout = v
|
||||
}
|
||||
if blockID != "" {
|
||||
if parsedID, err := uuid.Parse(blockID); err == nil {
|
||||
sb.WriteString(resolver.RenderEmbed(ctx, parsedID, dataSource, layout))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case "statement":
|
||||
sb.WriteString("<div class=\"bn-statement\">\n")
|
||||
if len(content) > 0 {
|
||||
sb.WriteString("<p>")
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
sb.WriteString("</p>\n")
|
||||
}
|
||||
if childrenHTML != "" {
|
||||
sb.WriteString(childrenHTML)
|
||||
}
|
||||
sb.WriteString("</div>\n")
|
||||
|
||||
case "humanProof":
|
||||
if hp := blocks.GetHumanProofBanner(ctx); hp != nil {
|
||||
sb.WriteString(blocks.RenderHumanProofBanner(hp))
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
|
||||
case "references":
|
||||
sb.WriteString("<div class=\"bn-references\">\n")
|
||||
sb.WriteString("<div class=\"bn-references-label\">References</div>\n")
|
||||
sb.WriteString("<ol>\n")
|
||||
if itemsJSON, ok := props["items"].(string); ok && itemsJSON != "" {
|
||||
var items []map[string]string
|
||||
if err := json.Unmarshal([]byte(itemsJSON), &items); err == nil {
|
||||
for _, item := range items {
|
||||
text := html.EscapeString(item["text"])
|
||||
url := item["url"]
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
if url != "" {
|
||||
fmt.Fprintf(&sb, "<li><a href=\"%s\">%s</a></li>\n", html.EscapeString(url), text)
|
||||
} else {
|
||||
fmt.Fprintf(&sb, "<li>%s</li>\n", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sb.WriteString("</ol>\n</div>\n")
|
||||
|
||||
default:
|
||||
if len(content) > 0 || childrenHTML != "" {
|
||||
sb.WriteString("<div>")
|
||||
sb.WriteString(renderInlineContent(content, false))
|
||||
if childrenHTML != "" {
|
||||
sb.WriteString(childrenHTML)
|
||||
}
|
||||
sb.WriteString("</div>\n")
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderChildren(ctx context.Context, children any) string {
|
||||
blocks := blocksFromRaw(children)
|
||||
if len(blocks) == 0 {
|
||||
return ""
|
||||
}
|
||||
return renderBlocks(ctx, blocks)
|
||||
}
|
||||
|
||||
func renderInlineContent(content []map[string]any, insideLink bool) string {
|
||||
var sb strings.Builder
|
||||
for _, itemMap := range content {
|
||||
itemType, _ := itemMap["type"].(string)
|
||||
text, _ := itemMap["text"].(string)
|
||||
styles, _ := itemMap["styles"].(map[string]any)
|
||||
|
||||
switch itemType {
|
||||
case "text":
|
||||
isCode := false
|
||||
if styles != nil {
|
||||
if c, ok := styles["code"].(bool); ok && c {
|
||||
isCode = true
|
||||
}
|
||||
}
|
||||
var rendered string
|
||||
if insideLink || isCode {
|
||||
rendered = html.EscapeString(text)
|
||||
} else {
|
||||
rendered = autolinkText(text)
|
||||
}
|
||||
if styles != nil {
|
||||
if bold, ok := styles["bold"].(bool); ok && bold {
|
||||
rendered = "<strong>" + rendered + "</strong>"
|
||||
}
|
||||
if italic, ok := styles["italic"].(bool); ok && italic {
|
||||
rendered = "<em>" + rendered + "</em>"
|
||||
}
|
||||
if underline, ok := styles["underline"].(bool); ok && underline {
|
||||
rendered = "<u>" + rendered + "</u>"
|
||||
}
|
||||
if strike, ok := styles["strike"].(bool); ok && strike {
|
||||
rendered = "<s>" + rendered + "</s>"
|
||||
}
|
||||
if strike, ok := styles["strikethrough"].(bool); ok && strike {
|
||||
rendered = "<s>" + rendered + "</s>"
|
||||
}
|
||||
if isCode {
|
||||
rendered = "<code>" + rendered + "</code>"
|
||||
}
|
||||
|
||||
var colorClasses []string
|
||||
if textColor, ok := styles["textColor"].(string); ok && textColor != "" && textColor != "default" {
|
||||
if class := colorValueToClass(textColor, "text"); class != "" {
|
||||
colorClasses = append(colorClasses, class)
|
||||
}
|
||||
}
|
||||
if bgColor, ok := styles["backgroundColor"].(string); ok && bgColor != "" && bgColor != "default" {
|
||||
if class := colorValueToClass(bgColor, "bg"); class != "" {
|
||||
colorClasses = append(colorClasses, class)
|
||||
}
|
||||
}
|
||||
|
||||
if len(colorClasses) > 0 {
|
||||
rendered = fmt.Sprintf(`<span class="%s">%s</span>`, strings.Join(colorClasses, " "), rendered)
|
||||
}
|
||||
}
|
||||
sb.WriteString(rendered)
|
||||
|
||||
case "link":
|
||||
href, _ := itemMap["href"].(string)
|
||||
linkContent := inlineContentFromRaw(itemMap["content"])
|
||||
if href == "" {
|
||||
sb.WriteString(renderInlineContent(linkContent, insideLink))
|
||||
break
|
||||
}
|
||||
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
|
||||
sb.WriteString(renderInlineContent(linkContent, true))
|
||||
sb.WriteString("</a>")
|
||||
|
||||
case "hardBreak":
|
||||
sb.WriteString("<br />")
|
||||
|
||||
default:
|
||||
if text != "" {
|
||||
if insideLink {
|
||||
sb.WriteString(html.EscapeString(text))
|
||||
} else {
|
||||
sb.WriteString(autolinkText(text))
|
||||
}
|
||||
break
|
||||
}
|
||||
if rawContent, ok := itemMap["content"]; ok {
|
||||
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent), insideLink))
|
||||
}
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// imageChipClass matches the page-builder image block's attribution chip
|
||||
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
|
||||
// blog and page credits identically.
|
||||
const imageChipClass = "absolute bottom-2 right-2 z-10 px-2 py-1 rounded-md bg-background/80 backdrop-blur-sm text-xs text-muted-foreground"
|
||||
|
||||
// buildAttributionHTML renders the escaped "Photo by NAME on SOURCE" credit,
|
||||
// or "" when there is no photographer to credit. The "on SOURCE" suffix is
|
||||
// omitted when no source is set.
|
||||
func buildAttributionHTML(att *blocks.MediaAttribution, class string) string {
|
||||
if att == nil || att.Photographer == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(`<span`)
|
||||
if class != "" {
|
||||
fmt.Fprintf(&b, ` class="%s"`, html.EscapeString(class))
|
||||
}
|
||||
b.WriteString(`>Photo by `)
|
||||
writeAttributionLink(&b, att.PhotographerURL, att.Photographer)
|
||||
if att.Source != "" {
|
||||
sourceLabel := strings.ToUpper(att.Source[:1]) + att.Source[1:]
|
||||
b.WriteString(` on `)
|
||||
writeAttributionLink(&b, att.SourceURL, sourceLabel)
|
||||
}
|
||||
b.WriteString(`</span>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func writeAttributionLink(b *strings.Builder, href, label string) {
|
||||
if href != "" && (strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "http://")) {
|
||||
fmt.Fprintf(b, `<a href="%s" target="_blank" rel="noopener noreferrer" class="underline">%s</a>`,
|
||||
html.EscapeString(href), html.EscapeString(label))
|
||||
return
|
||||
}
|
||||
b.WriteString(html.EscapeString(label))
|
||||
}
|
||||
|
||||
// autolinkText escapes plain text for HTML output and wraps any https:// URL
|
||||
// substring in <a href="..." rel="noopener">URL</a>. Bare domains, www. and
|
||||
// http:// URLs are intentionally not auto-linked.
|
||||
//
|
||||
// Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL.
|
||||
// Closing parens, brackets and braces are kept inside the URL only when
|
||||
// balanced with an opener inside the URL itself — so
|
||||
// "(see https://example.com)" links only "https://example.com"
|
||||
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
|
||||
func autolinkText(text string) string {
|
||||
const scheme = "https://"
|
||||
var sb strings.Builder
|
||||
rest := text
|
||||
for {
|
||||
idx := strings.Index(rest, scheme)
|
||||
if idx < 0 {
|
||||
sb.WriteString(html.EscapeString(rest))
|
||||
return sb.String()
|
||||
}
|
||||
sb.WriteString(html.EscapeString(rest[:idx]))
|
||||
|
||||
// Scan forward until whitespace or a URL-terminating delimiter.
|
||||
end := idx + len(scheme)
|
||||
for end < len(rest) {
|
||||
c := rest[end]
|
||||
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '<' || c == '>' || c == '"' || c == '\'' {
|
||||
break
|
||||
}
|
||||
end++
|
||||
}
|
||||
// Walk back over trailing characters that should sit outside the link,
|
||||
// preserving paren/bracket/brace balance.
|
||||
urlEnd := end
|
||||
for urlEnd > idx+len(scheme) {
|
||||
last := rest[urlEnd-1]
|
||||
if last == '.' || last == ',' || last == ';' || last == ':' || last == '!' || last == '?' || last == '"' || last == '\'' {
|
||||
urlEnd--
|
||||
continue
|
||||
}
|
||||
candidate := rest[idx:urlEnd]
|
||||
if last == ')' && strings.Count(candidate, ")") > strings.Count(candidate, "(") {
|
||||
urlEnd--
|
||||
continue
|
||||
}
|
||||
if last == ']' && strings.Count(candidate, "]") > strings.Count(candidate, "[") {
|
||||
urlEnd--
|
||||
continue
|
||||
}
|
||||
if last == '}' && strings.Count(candidate, "}") > strings.Count(candidate, "{") {
|
||||
urlEnd--
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
url := rest[idx:urlEnd]
|
||||
tail := rest[urlEnd:end]
|
||||
|
||||
if url == scheme {
|
||||
sb.WriteString(html.EscapeString(rest[idx:end]))
|
||||
} else {
|
||||
escaped := html.EscapeString(url)
|
||||
sb.WriteString(`<a href="`)
|
||||
sb.WriteString(escaped)
|
||||
sb.WriteString(`" rel="noopener">`)
|
||||
sb.WriteString(escaped)
|
||||
sb.WriteString(`</a>`)
|
||||
if tail != "" {
|
||||
sb.WriteString(html.EscapeString(tail))
|
||||
}
|
||||
}
|
||||
|
||||
rest = rest[end:]
|
||||
}
|
||||
}
|
||||
229
render/blocknote_autolink_test.go
Normal file
229
render/blocknote_autolink_test.go
Normal file
@ -0,0 +1,229 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAutolinkText_PlainTextPassthrough(t *testing.T) {
|
||||
got := autolinkText("just some prose with no link")
|
||||
want := "just some prose with no link"
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_HTMLMetacharactersEscaped(t *testing.T) {
|
||||
got := autolinkText("a < b && c > d")
|
||||
if !strings.Contains(got, "<") || !strings.Contains(got, ">") || !strings.Contains(got, "&") {
|
||||
t.Errorf("expected escaped metacharacters, got %q", got)
|
||||
}
|
||||
if strings.Contains(got, "<a ") {
|
||||
t.Errorf("did not expect <a> tag in plain prose: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_SingleHTTPSURL(t *testing.T) {
|
||||
got := autolinkText("see https://example.com for more")
|
||||
want := `see <a href="https://example.com" rel="noopener">https://example.com</a> for more`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_HTTPNotLinked(t *testing.T) {
|
||||
got := autolinkText("see http://example.com for more")
|
||||
if strings.Contains(got, "<a ") {
|
||||
t.Errorf("http:// should not be auto-linked, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "http://example.com") {
|
||||
t.Errorf("expected URL to appear as plain escaped text, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_BareDomainNotLinked(t *testing.T) {
|
||||
got := autolinkText("see example.com for more")
|
||||
if strings.Contains(got, "<a ") {
|
||||
t.Errorf("bare domain should not be auto-linked, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_TrailingPeriodOutsideAnchor(t *testing.T) {
|
||||
got := autolinkText("visit https://example.com.")
|
||||
want := `visit <a href="https://example.com" rel="noopener">https://example.com</a>.`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_TrailingPunctuationStripped(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
wantTail string
|
||||
}{
|
||||
{"check https://example.com,", ","},
|
||||
{"is it https://example.com?", "?"},
|
||||
{"wow https://example.com!", "!"},
|
||||
{"so https://example.com;", ";"},
|
||||
{"foo https://example.com:", ":"},
|
||||
{`he said "https://example.com"`, `"`},
|
||||
{"foo https://example.com'", "'"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := autolinkText(tc.input)
|
||||
if !strings.Contains(got, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||
t.Errorf("input %q: expected URL link without trailing punctuation, got %q", tc.input, got)
|
||||
}
|
||||
if !strings.HasSuffix(got, tc.wantTail) {
|
||||
t.Errorf("input %q: expected suffix %q, got %q", tc.input, tc.wantTail, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_ClosingParenOutsideAnchorWhenUnbalanced(t *testing.T) {
|
||||
got := autolinkText("(see https://example.com)")
|
||||
want := `(see <a href="https://example.com" rel="noopener">https://example.com</a>)`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_ClosingParenKeptWhenBalanced(t *testing.T) {
|
||||
got := autolinkText("see https://en.wikipedia.org/wiki/Foo_(bar) page")
|
||||
if !strings.Contains(got, `<a href="https://en.wikipedia.org/wiki/Foo_(bar)" rel="noopener">https://en.wikipedia.org/wiki/Foo_(bar)</a>`) {
|
||||
t.Errorf("expected paren kept inside anchor when balanced, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_MultipleURLs(t *testing.T) {
|
||||
got := autolinkText("first https://a.com then https://b.com end")
|
||||
if strings.Count(got, "<a ") != 2 {
|
||||
t.Errorf("expected 2 anchors, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, `href="https://a.com"`) || !strings.Contains(got, `href="https://b.com"`) {
|
||||
t.Errorf("expected both URLs linked, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_URLWithQueryStringContainingAmpersand(t *testing.T) {
|
||||
got := autolinkText("see https://example.com/?a=1&b=2 ok")
|
||||
// `&` should be escaped to `&` in both href and text
|
||||
if !strings.Contains(got, `href="https://example.com/?a=1&b=2"`) {
|
||||
t.Errorf("expected escaped ampersand in href, got %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "rel=\"noopener\"") {
|
||||
t.Errorf("expected rel=noopener, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_URLAtStringStart(t *testing.T) {
|
||||
got := autolinkText("https://example.com is great")
|
||||
if !strings.HasPrefix(got, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||
t.Errorf("expected anchor at start, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutolinkText_URLAtStringEnd(t *testing.T) {
|
||||
got := autolinkText("checkout https://example.com")
|
||||
want := `checkout <a href="https://example.com" rel="noopener">https://example.com</a>`
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Integration tests: confirm autolinking flows through the public renderer
|
||||
|
||||
func TestBlockNoteToHTML_AutolinksURLInParagraph(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "visit https://example.com today"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, `<a href="https://example.com" rel="noopener">https://example.com</a>`) {
|
||||
t.Errorf("expected autolinked URL in paragraph, got %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockNoteToHTML_NoNestedAnchorInsideExplicitLink(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "link",
|
||||
"href": "https://short.url/",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "see https://full-url-text.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
// Outer link should exist
|
||||
if !strings.Contains(html, `<a href="https://short.url/">`) {
|
||||
t.Errorf("expected outer explicit link, got %s", html)
|
||||
}
|
||||
// Inner text must NOT become a nested anchor
|
||||
if strings.Contains(html, `<a href="https://full-url-text.com"`) {
|
||||
t.Errorf("did not expect nested anchor inside link, got %s", html)
|
||||
}
|
||||
// Inner URL should appear as escaped plain text
|
||||
if !strings.Contains(html, "https://full-url-text.com") {
|
||||
t.Errorf("expected inner URL as plain text, got %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockNoteToHTML_NoAutolinkInsideCodeStyle(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "https://example.com",
|
||||
"styles": map[string]any{"code": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if strings.Contains(html, "<a ") {
|
||||
t.Errorf("did not expect anchor inside code-styled text, got %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<code>") || !strings.Contains(html, "https://example.com") {
|
||||
t.Errorf("expected code-wrapped literal URL, got %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockNoteToHTML_BoldWrapsAutolink(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "https://example.com",
|
||||
"styles": map[string]any{"bold": true},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, `<strong><a href="https://example.com" rel="noopener">https://example.com</a></strong>`) {
|
||||
t.Errorf("expected bold-wrapped autolink, got %s", html)
|
||||
}
|
||||
}
|
||||
782
render/blocknote_test.go
Normal file
782
render/blocknote_test.go
Normal file
@ -0,0 +1,782 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type testEmbedResolver struct{}
|
||||
|
||||
func (testEmbedResolver) RenderEmbed(_ context.Context, blockID uuid.UUID, dataSource, layout string) string {
|
||||
return "embed:" + blockID.String() + ":" + dataSource + ":" + layout
|
||||
}
|
||||
|
||||
func TestBlockNoteToHTMLUsesSDKContextResolvers(t *testing.T) {
|
||||
blockID := uuid.MustParse("11111111-1111-1111-1111-111111111111")
|
||||
ctx := blocks.WithEmbedResolver(context.Background(), testEmbedResolver{})
|
||||
ctx = blocks.WithHumanProofBanner(ctx, &blocks.HumanProofBannerData{
|
||||
ActiveTimeMinutes: 12,
|
||||
KeystrokeCount: 3456,
|
||||
SessionCount: 2,
|
||||
PostSlug: "proof-post",
|
||||
})
|
||||
|
||||
html := BlockNoteToHTML(ctx, map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "embed",
|
||||
"props": map[string]any{
|
||||
"blockId": blockID.String(),
|
||||
"dataSource": "row:22222222-2222-2222-2222-222222222222",
|
||||
"layout": "card",
|
||||
},
|
||||
},
|
||||
map[string]any{"type": "humanProof"},
|
||||
},
|
||||
})
|
||||
|
||||
if !strings.Contains(html, "embed:"+blockID.String()+":row:22222222-2222-2222-2222-222222222222:card") {
|
||||
t.Fatalf("BlockNoteToHTML() did not render embed from SDK context: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `data-human-proof-banner`) || !strings.Contains(html, `proof-post`) {
|
||||
t.Fatalf("BlockNoteToHTML() did not render human proof banner from SDK context: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextAlignment(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
blockType string
|
||||
alignment string
|
||||
wantClass string
|
||||
}{
|
||||
{"paragraph center", "paragraph", "center", "text-center"},
|
||||
{"paragraph right", "paragraph", "right", "text-right"},
|
||||
{"heading center", "heading", "center", "text-center"},
|
||||
{"quote justify", "quote", "justify", "text-justify"},
|
||||
{"paragraph left", "paragraph", "left", "text-left"},
|
||||
{"paragraph default", "paragraph", "", "my-4"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
props := map[string]any{}
|
||||
if tt.alignment != "" {
|
||||
props["textAlignment"] = tt.alignment
|
||||
}
|
||||
if tt.blockType == "heading" {
|
||||
props["level"] = float64(2)
|
||||
}
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": tt.blockType,
|
||||
"props": props,
|
||||
"content": "Test",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, tt.wantClass) {
|
||||
t.Errorf("expected class %q in output: %s", tt.wantClass, html)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckListItem(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "checkListItem",
|
||||
"props": map[string]any{"checked": true},
|
||||
"content": "Done task",
|
||||
},
|
||||
map[string]any{
|
||||
"type": "checkListItem",
|
||||
"props": map[string]any{"checked": false},
|
||||
"content": "Todo task",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `checked`) {
|
||||
t.Errorf("expected checked attribute: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Done task") || !strings.Contains(html, "Todo task") {
|
||||
t.Errorf("expected checklist content: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `type="checkbox"`) {
|
||||
t.Errorf("expected checkbox input: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleListItem(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "toggleListItem",
|
||||
"content": "Details",
|
||||
"children": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": "Nested content",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, "<details") {
|
||||
t.Errorf("expected details element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<summary") {
|
||||
t.Errorf("expected summary element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Nested content") {
|
||||
t.Errorf("expected nested content: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleListItemOpen(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "toggleListItem",
|
||||
"props": map[string]any{"open": true},
|
||||
"content": "Open toggle",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, ` open`) {
|
||||
t.Errorf("expected open attribute: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "video",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/video.mp4",
|
||||
"caption": "My video",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `<video`) {
|
||||
t.Errorf("expected video element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `controls`) {
|
||||
t.Errorf("expected controls attribute: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "My video") {
|
||||
t.Errorf("expected caption: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAudioBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "audio",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/audio.mp3",
|
||||
"caption": "Podcast episode",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `<audio`) {
|
||||
t.Errorf("expected audio element: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `controls`) {
|
||||
t.Errorf("expected controls attribute: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Podcast episode") {
|
||||
t.Errorf("expected caption: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "file",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/doc.pdf",
|
||||
"name": "Document.pdf",
|
||||
"caption": "Download here",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `href="https://example.com/doc.pdf"`) {
|
||||
t.Errorf("expected file link: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Document.pdf") {
|
||||
t.Errorf("expected file name: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "Download here") {
|
||||
t.Errorf("expected caption: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileBlockNoName(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "file",
|
||||
"props": map[string]any{
|
||||
"url": "https://example.com/doc.pdf",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "https://example.com/doc.pdf") {
|
||||
t.Errorf("expected URL as fallback name: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "statement",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "This is a key statement.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `class="bn-statement"`) {
|
||||
t.Errorf("expected bn-statement class: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "This is a key statement.") {
|
||||
t.Errorf("expected statement text: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatementBlockWithFormatting(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "statement",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "Bold text",
|
||||
"styles": map[string]any{"bold": true},
|
||||
},
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": " and normal.",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, "<strong>Bold text</strong>") {
|
||||
t.Errorf("expected bold formatting: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorValueToClass(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
prefix string
|
||||
expected string
|
||||
}{
|
||||
{"empty", "", "text", ""},
|
||||
{"default", "default", "text", ""},
|
||||
{"theme primary text", "primary", "text", "text-primary"},
|
||||
{"theme primary bg", "primary", "bg", "bg-primary"},
|
||||
{"theme foreground", "foreground", "text", "text-foreground"},
|
||||
{"theme muted-foreground", "muted-foreground", "text", "text-muted-foreground"},
|
||||
{"custom color text", "custom:brand", "text", "text-[hsl(var(--color-brand))]"},
|
||||
{"custom color bg", "custom:brand", "bg", "bg-[hsl(var(--color-brand))]"},
|
||||
{"hex color text", "hex:#ff5500", "text", "text-[#ff5500]"},
|
||||
{"hex color bg", "hex:#ff5500", "bg", "bg-[#ff5500]"},
|
||||
{"short hex", "hex:#f00", "text", "text-[#f00]"},
|
||||
{"invalid hex length", "hex:#ff", "text", ""},
|
||||
{"invalid hex char", "hex:#gggggg", "text", ""},
|
||||
{"custom with invalid chars", "custom:bad name", "text", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := colorValueToClass(tt.input, tt.prefix)
|
||||
if got != tt.expected {
|
||||
t.Errorf("colorValueToClass(%q, %q) = %q, want %q", tt.input, tt.prefix, got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInlineContentWithTextColor(t *testing.T) {
|
||||
content := []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Hello",
|
||||
"styles": map[string]any{
|
||||
"textColor": "primary",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := renderInlineContent(content, false)
|
||||
|
||||
if !strings.Contains(html, `class="text-primary"`) {
|
||||
t.Errorf("expected text color class: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInlineContentWithBackgroundColor(t *testing.T) {
|
||||
content := []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Highlighted",
|
||||
"styles": map[string]any{
|
||||
"backgroundColor": "hex:#ffcc00",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := renderInlineContent(content, false)
|
||||
|
||||
if !strings.Contains(html, `bg-[#ffcc00]`) {
|
||||
t.Errorf("expected background color class: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInlineContentWithBothColors(t *testing.T) {
|
||||
content := []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Styled",
|
||||
"styles": map[string]any{
|
||||
"textColor": "foreground",
|
||||
"backgroundColor": "custom:highlight",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := renderInlineContent(content, false)
|
||||
|
||||
if !strings.Contains(html, `text-foreground`) {
|
||||
t.Errorf("expected text color class: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `bg-[hsl(var(--color-highlight))]`) {
|
||||
t.Errorf("expected background color class: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInlineContentWithDefaultColor(t *testing.T) {
|
||||
content := []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Normal",
|
||||
"styles": map[string]any{
|
||||
"textColor": "default",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := renderInlineContent(content, false)
|
||||
|
||||
if strings.Contains(html, "class=") {
|
||||
t.Errorf("default color should not add class: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderInlineContentWithColorsAndOtherStyles(t *testing.T) {
|
||||
content := []map[string]any{
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Bold and colored",
|
||||
"styles": map[string]any{
|
||||
"bold": true,
|
||||
"textColor": "hex:#ff0000",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := renderInlineContent(content, false)
|
||||
|
||||
if !strings.Contains(html, "<strong>") {
|
||||
t.Errorf("expected bold tag: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `text-[#ff0000]`) {
|
||||
t.Errorf("expected text color class: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockNoteToHTMLWithColoredParagraph(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{
|
||||
map[string]any{
|
||||
"type": "text",
|
||||
"text": "Red text",
|
||||
"styles": map[string]any{
|
||||
"textColor": "hex:#ff0000",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, "<p") {
|
||||
t.Errorf("expected paragraph tag: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `text-[#ff0000]`) {
|
||||
t.Errorf("expected text color class: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBulletListStringContent(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "bulletListItem",
|
||||
"content": "Test bullet content",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "Test bullet content") {
|
||||
t.Errorf("expected bullet content: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableSingleRowDoesNotEmitTbody(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "table",
|
||||
"content": map[string]any{
|
||||
"rows": []any{
|
||||
map[string]any{
|
||||
"cells": []any{
|
||||
[]any{map[string]any{"type": "text", "text": "Header"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "<thead>") {
|
||||
t.Errorf("expected thead: %s", html)
|
||||
}
|
||||
if strings.Contains(html, "<tbody>") {
|
||||
t.Errorf("did not expect tbody for single-row table: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableCellStringContent(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "table",
|
||||
"content": map[string]any{
|
||||
"rows": []any{
|
||||
map[string]any{"cells": []any{"Header", "Value"}},
|
||||
map[string]any{"cells": []any{"Row", "Cell"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "Header") || !strings.Contains(html, "Cell") {
|
||||
t.Errorf("expected table cell content: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<tbody>") {
|
||||
t.Errorf("expected tbody for multi-row table: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
// BlockNote >=0.15 (the editor ships 0.47) stores each table cell as a
|
||||
// tableCell object: {type:"tableCell", props:{...}, content:[...inline]}.
|
||||
// The renderer must unwrap content[] from these objects, not just bare
|
||||
// inline arrays/strings.
|
||||
func TestTableCellObjectContent(t *testing.T) {
|
||||
cell := func(text string) map[string]any {
|
||||
return map[string]any{
|
||||
"type": "tableCell",
|
||||
"props": map[string]any{},
|
||||
"content": []any{map[string]any{"type": "text", "text": text}},
|
||||
}
|
||||
}
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "table",
|
||||
"content": map[string]any{
|
||||
"type": "tableContent",
|
||||
"rows": []any{
|
||||
map[string]any{"cells": []any{cell("Header A"), cell("Header B")}},
|
||||
map[string]any{"cells": []any{cell("Cell A"), cell("Cell B")}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
for _, want := range []string{"Header A", "Header B", "Cell A", "Cell B"} {
|
||||
if !strings.Contains(html, want) {
|
||||
t.Errorf("expected table cell content %q in output: %s", want, html)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedListRendering(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "bulletListItem",
|
||||
"content": "Parent",
|
||||
"children": []any{
|
||||
map[string]any{
|
||||
"type": "bulletListItem",
|
||||
"content": "Child",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "Parent") || !strings.Contains(html, "Child") {
|
||||
t.Errorf("expected nested list content: %s", html)
|
||||
}
|
||||
if strings.Count(html, "<ul") < 2 {
|
||||
t.Errorf("expected nested ul elements: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHardBreakInlineContent(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{
|
||||
map[string]any{"type": "text", "text": "Line1"},
|
||||
map[string]any{"type": "hardBreak"},
|
||||
map[string]any{"type": "text", "text": "Line2"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "Line1") || !strings.Contains(html, "Line2") || !strings.Contains(html, "<br />") {
|
||||
t.Errorf("expected hard break in output: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencesBlock(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "references",
|
||||
"props": map[string]any{
|
||||
"items": `[{"text":"Steve Blank","url":"https://example.com/blank"},{"text":"Charity Majors","url":""}]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
|
||||
if !strings.Contains(html, `class="bn-references"`) {
|
||||
t.Errorf("expected bn-references class: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `<a href="https://example.com/blank">Steve Blank`) {
|
||||
t.Errorf("expected linked reference: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, "<li>Charity Majors") {
|
||||
t.Errorf("expected plain text reference: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencesBlockEmpty(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "references",
|
||||
"props": map[string]any{"items": "[]"},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, `class="bn-references"`) {
|
||||
t.Errorf("expected bn-references wrapper even when empty: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencesBlockMalformedJSON(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "references",
|
||||
"props": map[string]any{"items": "not valid json"},
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, `class="bn-references"`) {
|
||||
t.Errorf("expected graceful fallback: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyDocument(t *testing.T) {
|
||||
html := BlockNoteToHTML(context.Background(), map[string]any{})
|
||||
if html != "" {
|
||||
t.Errorf("expected empty output for empty doc: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownBlockType(t *testing.T) {
|
||||
doc := map[string]any{
|
||||
"blocks": []any{
|
||||
map[string]any{
|
||||
"type": "unknownBlock",
|
||||
"content": "Some content",
|
||||
},
|
||||
},
|
||||
}
|
||||
html := BlockNoteToHTML(context.Background(), doc)
|
||||
if !strings.Contains(html, "Some content") {
|
||||
t.Errorf("expected fallback rendering of unknown block: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
type testMediaResolver struct {
|
||||
att *blocks.MediaAttribution
|
||||
}
|
||||
|
||||
func (r testMediaResolver) ResolveMedia(_ context.Context, mediaID uuid.UUID) *blocks.MediaValue {
|
||||
return &blocks.MediaValue{Src: "/media/" + mediaID.String(), Attribution: r.att}
|
||||
}
|
||||
|
||||
func imageBlock(props map[string]any) map[string]any {
|
||||
return map[string]any{"blocks": []any{map[string]any{"type": "image", "props": props}}}
|
||||
}
|
||||
|
||||
// Legacy blocks (url/caption only) must render byte-identical to the old output.
|
||||
func TestImageBlockLegacyUnchanged(t *testing.T) {
|
||||
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "https://example.com/pic.jpg", "caption": "A pic",
|
||||
}))
|
||||
want := "<figure class=\"my-6\"><img src=\"https://example.com/pic.jpg\" alt=\"A pic\" /><figcaption>A pic</figcaption></figure>\n"
|
||||
if html != want {
|
||||
t.Fatalf("legacy image output changed:\n got: %s\nwant: %s", html, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockAltAndLink(t *testing.T) {
|
||||
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "/media/x.webp", "alt": "Sunset", "link": "https://example.com/story",
|
||||
}))
|
||||
if !strings.Contains(html, `alt="Sunset"`) {
|
||||
t.Fatalf("alt not rendered: %s", html)
|
||||
}
|
||||
if !strings.Contains(html, `<a href="https://example.com/story"><img `) {
|
||||
t.Fatalf("link wrap not rendered: %s", html)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockWidthAndAlign(t *testing.T) {
|
||||
html := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "/media/x.webp", "width": "medium", "align": "right",
|
||||
}))
|
||||
if !strings.Contains(html, `max-w-[600px]`) || !strings.Contains(html, `float-right ml-6 mb-4`) {
|
||||
t.Fatalf("width/align classes missing: %s", html)
|
||||
}
|
||||
|
||||
centered := BlockNoteToHTML(context.Background(), imageBlock(map[string]any{
|
||||
"url": "/media/x.webp", "width": "small", "align": "center",
|
||||
}))
|
||||
if !strings.Contains(centered, `max-w-[400px]`) || !strings.Contains(centered, `mx-auto`) {
|
||||
t.Fatalf("centered width classes missing: %s", centered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockAttributionModes(t *testing.T) {
|
||||
mediaID := uuid.MustParse("33333333-3333-3333-3333-333333333333")
|
||||
att := &blocks.MediaAttribution{
|
||||
Photographer: "Jane Doe",
|
||||
PhotographerURL: "https://www.pexels.com/@janedoe",
|
||||
Source: "pexels",
|
||||
SourceURL: "https://www.pexels.com/photo/123",
|
||||
}
|
||||
ctx := blocks.WithMediaResolver(context.Background(), testMediaResolver{att: att})
|
||||
props := func(mode string) map[string]any {
|
||||
return map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String(), "attributionMode": mode}
|
||||
}
|
||||
|
||||
chip := BlockNoteToHTML(ctx, imageBlock(props("chip")))
|
||||
if !strings.Contains(chip, "Photo by ") || !strings.Contains(chip, "Jane Doe") ||
|
||||
!strings.Contains(chip, "on ") || !strings.Contains(chip, "Pexels") ||
|
||||
!strings.Contains(chip, `absolute bottom-2 right-2`) {
|
||||
t.Fatalf("chip mode wrong: %s", chip)
|
||||
}
|
||||
|
||||
hover := BlockNoteToHTML(ctx, imageBlock(props("chipHover")))
|
||||
if !strings.Contains(hover, `group-hover:opacity-100`) || !strings.Contains(hover, `class="group relative overflow-hidden"`) {
|
||||
t.Fatalf("chipHover mode wrong: %s", hover)
|
||||
}
|
||||
|
||||
below := BlockNoteToHTML(ctx, imageBlock(props("below")))
|
||||
if !strings.Contains(below, `mt-1 text-xs text-muted-foreground text-center`) || !strings.Contains(below, "Jane Doe") {
|
||||
t.Fatalf("below mode wrong: %s", below)
|
||||
}
|
||||
|
||||
off := BlockNoteToHTML(ctx, imageBlock(map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String()}))
|
||||
if strings.Contains(off, "Photo by") {
|
||||
t.Fatalf("default mode must render no credit: %s", off)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageBlockAttributionDegradesGracefully(t *testing.T) {
|
||||
mediaID := uuid.MustParse("44444444-4444-4444-4444-444444444444")
|
||||
props := imageBlock(map[string]any{"url": "/media/x.webp", "mediaId": mediaID.String(), "attributionMode": "chip"})
|
||||
|
||||
// No resolver in ctx.
|
||||
if html := BlockNoteToHTML(context.Background(), props); strings.Contains(html, "Photo by") {
|
||||
t.Fatalf("no-resolver ctx must render no credit: %s", html)
|
||||
}
|
||||
// Resolver returns media without attribution.
|
||||
ctx := blocks.WithMediaResolver(context.Background(), testMediaResolver{att: nil})
|
||||
if html := BlockNoteToHTML(ctx, props); strings.Contains(html, "Photo by") {
|
||||
t.Fatalf("nil attribution must render no credit: %s", html)
|
||||
}
|
||||
// Photographer-only credit omits the "on SOURCE" suffix.
|
||||
ctx = blocks.WithMediaResolver(context.Background(), testMediaResolver{att: &blocks.MediaAttribution{Photographer: "Jane"}})
|
||||
html := BlockNoteToHTML(ctx, props)
|
||||
if !strings.Contains(html, "Photo by ") || strings.Contains(html, " on ") {
|
||||
t.Fatalf("photographer-only credit wrong: %s", html)
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
package bn
|
||||
|
||||
// VersionedAssetURL resolves an asset URL to a cache-versioned form. The
|
||||
// default is the identity function; the CMS host wires it to
|
||||
// internal/assets.VersionedURL at startup so plugin stylesheet links get
|
||||
// content-hash ?v= params. Guest-side (wasm) renders keep the identity
|
||||
// default — asset hashing is a host concern.
|
||||
//
|
||||
// This file is part of the cms→core template sync set (make sync-templates);
|
||||
// it must stay SDK-clean (no cms imports).
|
||||
var VersionedAssetURL = func(url string) string { return url }
|
||||
@ -1,514 +0,0 @@
|
||||
package bn
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// EngagementConfig contains configuration for the engagement tracker
|
||||
type EngagementConfig struct {
|
||||
PagePath string `json:"pagePath"`
|
||||
PageID string `json:"pageId"`
|
||||
PostWordCount int `json:"postWordCount"`
|
||||
SessionID string `json:"sessionId"`
|
||||
VisitorHash string `json:"visitorHash"`
|
||||
IsPost bool `json:"isPost"`
|
||||
}
|
||||
|
||||
// engagementConfigJSON converts the config to JSON for embedding in the script
|
||||
func engagementConfigJSON(config EngagementConfig) string {
|
||||
data, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "{}"
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// EngagementScript renders the blog post engagement tracking script.
|
||||
// Only renders if IsPost is true to avoid bloating regular pages.
|
||||
templ EngagementScript(config EngagementConfig) {
|
||||
if config.IsPost && config.PageID != "" {
|
||||
<script data-engagement-config={ engagementConfigJSON(config) }>
|
||||
(function(){
|
||||
'use strict';
|
||||
|
||||
// Parse config from script tag
|
||||
var script = document.currentScript;
|
||||
var cfg;
|
||||
try { cfg = JSON.parse(script.getAttribute('data-engagement-config')); } catch(e) { return; }
|
||||
if (!cfg.isPost) return;
|
||||
|
||||
// =================================================================
|
||||
// State
|
||||
// =================================================================
|
||||
var state = {
|
||||
sessionId: cfg.sessionId || crypto.randomUUID(),
|
||||
visitorHash: cfg.visitorHash || '',
|
||||
pageStart: Date.now(),
|
||||
activeStart: Date.now(),
|
||||
totalActiveTime: 0,
|
||||
isActive: true,
|
||||
lastActivityTime: Date.now(),
|
||||
maxScrollPercent: 0,
|
||||
scrollMilestones: {},
|
||||
viewedHeadings: {},
|
||||
currentHeading: null,
|
||||
mouseMovements: 0,
|
||||
scrollEvents: [],
|
||||
lastScrollTime: 0,
|
||||
lastScrollPos: 0,
|
||||
directionChanges: 0,
|
||||
eventQueue: [],
|
||||
flushTimer: null,
|
||||
clickPositions: [],
|
||||
interactiveTags: {'A':1,'BUTTON':1,'INPUT':1,'SELECT':1,'TEXTAREA':1,'LABEL':1},
|
||||
sentExit: false
|
||||
};
|
||||
|
||||
// =================================================================
|
||||
// Utility Functions
|
||||
// =================================================================
|
||||
function getScrollPercent() {
|
||||
var docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||
if (docHeight <= 0) return 100;
|
||||
return Math.min(100, Math.round((window.scrollY / docHeight) * 100));
|
||||
}
|
||||
|
||||
function getContentDepthPercent() {
|
||||
var content = document.querySelector('[data-slot="main"]') ||
|
||||
document.querySelector('article') ||
|
||||
document.querySelector('main');
|
||||
if (!content) return getScrollPercent();
|
||||
|
||||
var rect = content.getBoundingClientRect();
|
||||
var contentTop = window.scrollY + rect.top;
|
||||
var contentHeight = rect.height;
|
||||
var viewportBottom = window.scrollY + window.innerHeight;
|
||||
|
||||
var contentViewed = Math.max(0, viewportBottom - contentTop);
|
||||
return Math.min(100, Math.round((contentViewed / contentHeight) * 100));
|
||||
}
|
||||
|
||||
function getVisibleHeadings() {
|
||||
var headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||
var visible = [];
|
||||
var viewportTop = window.scrollY;
|
||||
var viewportBottom = viewportTop + window.innerHeight;
|
||||
|
||||
for (var i = 0; i < headings.length; i++) {
|
||||
var h = headings[i];
|
||||
var rect = h.getBoundingClientRect();
|
||||
var absTop = window.scrollY + rect.top;
|
||||
var absBottom = absTop + rect.height;
|
||||
|
||||
if (absBottom >= viewportTop && absTop <= viewportBottom) {
|
||||
visible.push({
|
||||
id: h.id || 'heading-' + i,
|
||||
text: (h.textContent || '').trim().substring(0, 100),
|
||||
level: parseInt(h.tagName.substring(1)),
|
||||
top: absTop
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return visible;
|
||||
}
|
||||
|
||||
function hashPhrase(str) {
|
||||
var hash = 0;
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var chr = str.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + chr;
|
||||
hash |= 0;
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Quality Score Calculation
|
||||
// =================================================================
|
||||
function calculateQualityScore() {
|
||||
var score = 50;
|
||||
|
||||
// Positive signals
|
||||
if (state.mouseMovements > 10) score += 10;
|
||||
if (state.totalActiveTime > 30000) score += 15;
|
||||
if (state.scrollEvents.length > 5) score += 10;
|
||||
|
||||
// Scroll naturalness
|
||||
if (state.scrollEvents.length >= 3) {
|
||||
var intervals = [];
|
||||
for (var i = 1; i < state.scrollEvents.length; i++) {
|
||||
intervals.push(state.scrollEvents[i].time - state.scrollEvents[i-1].time);
|
||||
}
|
||||
var avg = intervals.reduce(function(a,b){ return a+b; }, 0) / intervals.length;
|
||||
var variance = intervals.reduce(function(sum, i){ return sum + Math.pow(i - avg, 2); }, 0) / intervals.length;
|
||||
if (variance > 50000) score += 10;
|
||||
if (variance < 1000 && intervals.length > 10) score -= 20;
|
||||
}
|
||||
|
||||
// Direction changes indicate engaged reading
|
||||
if (state.directionChanges > 2) score += 5;
|
||||
|
||||
// Time vs content
|
||||
var expectedReadTime = (cfg.postWordCount / 200) * 60 * 1000;
|
||||
var actualTime = Date.now() - state.pageStart;
|
||||
var readRatio = actualTime / expectedReadTime;
|
||||
if (readRatio > 0.3 && readRatio < 5) score += 10;
|
||||
|
||||
// Negative signals
|
||||
if (document.hidden && state.totalActiveTime < 5000) score -= 20;
|
||||
|
||||
// Instant 100% scroll is suspicious
|
||||
if (state.maxScrollPercent >= 100 && actualTime < 3000) score -= 30;
|
||||
|
||||
return Math.max(0, Math.min(100, score));
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Event Queue & Batching
|
||||
// =================================================================
|
||||
function queueEvent(type, name, data) {
|
||||
state.eventQueue.push({
|
||||
t: type,
|
||||
n: name,
|
||||
d: data,
|
||||
ts: Date.now()
|
||||
});
|
||||
|
||||
if (state.eventQueue.length >= 10) {
|
||||
flushEvents();
|
||||
} else if (!state.flushTimer) {
|
||||
state.flushTimer = setTimeout(flushEvents, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
function flushEvents() {
|
||||
if (state.flushTimer) {
|
||||
clearTimeout(state.flushTimer);
|
||||
state.flushTimer = null;
|
||||
}
|
||||
|
||||
if (state.eventQueue.length === 0) return;
|
||||
|
||||
var payload = {
|
||||
pp: cfg.pagePath,
|
||||
pid: cfg.pageId,
|
||||
sid: state.sessionId,
|
||||
vh: state.visitorHash,
|
||||
qs: calculateQualityScore(),
|
||||
events: state.eventQueue.slice()
|
||||
};
|
||||
|
||||
state.eventQueue = [];
|
||||
|
||||
navigator.sendBeacon('/api/engagement', JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Scroll Tracking
|
||||
// =================================================================
|
||||
var scrollThrottle = null;
|
||||
function handleScroll() {
|
||||
if (scrollThrottle) return;
|
||||
scrollThrottle = setTimeout(function() {
|
||||
scrollThrottle = null;
|
||||
|
||||
var percent = getScrollPercent();
|
||||
var contentPercent = getContentDepthPercent();
|
||||
var now = Date.now();
|
||||
|
||||
// Track scroll direction changes
|
||||
var currentPos = window.scrollY;
|
||||
if (state.lastScrollPos !== 0) {
|
||||
var wasGoingDown = state.scrollEvents.length > 0 &&
|
||||
state.scrollEvents[state.scrollEvents.length - 1].pos < currentPos;
|
||||
var isGoingDown = currentPos > state.lastScrollPos;
|
||||
if (state.scrollEvents.length > 1 && wasGoingDown !== isGoingDown) {
|
||||
state.directionChanges++;
|
||||
}
|
||||
}
|
||||
state.lastScrollPos = currentPos;
|
||||
|
||||
// Track scroll event for quality scoring
|
||||
state.scrollEvents.push({ time: now, percent: percent, pos: currentPos });
|
||||
if (state.scrollEvents.length > 50) state.scrollEvents.shift();
|
||||
|
||||
// Detect jump scrolling (>500px instant)
|
||||
if (state.scrollEvents.length >= 2) {
|
||||
var prev = state.scrollEvents[state.scrollEvents.length - 2];
|
||||
var posDiff = Math.abs(currentPos - prev.pos);
|
||||
var timeDiff = now - prev.time;
|
||||
if (posDiff > 500 && timeDiff < 50) {
|
||||
queueEvent('navigation', 'jump_scroll', { distance: posDiff });
|
||||
}
|
||||
}
|
||||
|
||||
// Detect erratic scrolling (rapid direction changes)
|
||||
if (state.directionChanges > 5 && now - state.pageStart < 10000) {
|
||||
queueEvent('frustration', 'erratic_scroll', {});
|
||||
state.directionChanges = 0;
|
||||
}
|
||||
|
||||
// Update max scroll
|
||||
if (percent > state.maxScrollPercent) {
|
||||
state.maxScrollPercent = percent;
|
||||
}
|
||||
|
||||
// Check milestones
|
||||
[25, 50, 75, 100].forEach(function(milestone) {
|
||||
if (percent >= milestone && !state.scrollMilestones[milestone]) {
|
||||
state.scrollMilestones[milestone] = true;
|
||||
queueEvent('scroll', 'scroll_' + milestone, {
|
||||
maxScrollPercent: state.maxScrollPercent,
|
||||
contentDepthPercent: contentPercent,
|
||||
timeToMilestoneMs: now - state.pageStart
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Track heading visibility
|
||||
var visible = getVisibleHeadings();
|
||||
var newHeading = visible[0];
|
||||
|
||||
if (state.currentHeading && (!newHeading || newHeading.id !== state.currentHeading.id)) {
|
||||
var entry = state.viewedHeadings[state.currentHeading.id];
|
||||
if (entry) {
|
||||
entry.totalDwell += now - entry.lastEnter;
|
||||
queueEvent('section', 'heading_viewed', {
|
||||
headingId: state.currentHeading.id,
|
||||
headingText: state.currentHeading.text,
|
||||
headingLevel: state.currentHeading.level,
|
||||
dwellTimeMs: entry.totalDwell
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (newHeading && (!state.currentHeading || newHeading.id !== state.currentHeading.id)) {
|
||||
if (!state.viewedHeadings[newHeading.id]) {
|
||||
state.viewedHeadings[newHeading.id] = { lastEnter: now, totalDwell: 0 };
|
||||
} else {
|
||||
var e = state.viewedHeadings[newHeading.id];
|
||||
e.lastEnter = now;
|
||||
queueEvent('section', 'reread', {
|
||||
headingId: newHeading.id,
|
||||
headingText: newHeading.text
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
state.currentHeading = newHeading;
|
||||
state.lastScrollTime = now;
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Visibility / Active Time Tracking
|
||||
// =================================================================
|
||||
function handleVisibilityChange() {
|
||||
var now = Date.now();
|
||||
if (document.hidden) {
|
||||
if (state.isActive) {
|
||||
state.totalActiveTime += now - state.activeStart;
|
||||
state.isActive = false;
|
||||
}
|
||||
// Early blur might indicate bounce intent
|
||||
if (now - state.pageStart < 10000 && state.maxScrollPercent < 25) {
|
||||
queueEvent('frustration', 'early_blur', { timeOnPage: now - state.pageStart });
|
||||
}
|
||||
} else {
|
||||
state.activeStart = now;
|
||||
state.isActive = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Idle detection
|
||||
function handleActivity() {
|
||||
state.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Interaction Tracking
|
||||
// =================================================================
|
||||
var selectionTimeout = null;
|
||||
function handleTextSelection() {
|
||||
if (selectionTimeout) clearTimeout(selectionTimeout);
|
||||
selectionTimeout = setTimeout(function() {
|
||||
var selection = window.getSelection();
|
||||
if (selection && selection.toString().trim().length > 5) {
|
||||
var text = selection.toString().trim();
|
||||
if (text.length > 50) text = text.substring(0, 50);
|
||||
|
||||
var range = selection.getRangeAt(0);
|
||||
var container = range.commonAncestorContainer;
|
||||
var nearestHeading = container.parentElement?.closest('h1,h2,h3,h4,h5,h6');
|
||||
|
||||
queueEvent('interaction', 'text_select', {
|
||||
phrase: text,
|
||||
phraseHash: hashPhrase(text),
|
||||
length: selection.toString().length,
|
||||
nearHeading: nearestHeading?.id || null,
|
||||
nearHeadingText: nearestHeading?.textContent?.substring(0, 50) || null
|
||||
});
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function handleCopy(e) {
|
||||
var selection = window.getSelection()?.toString() || '';
|
||||
queueEvent('interaction', 'copy', {
|
||||
length: selection.length
|
||||
});
|
||||
}
|
||||
|
||||
function handleLinkClick(e) {
|
||||
var link = e.target.closest('a');
|
||||
if (!link || !link.href) return;
|
||||
|
||||
var isExternal = !link.href.startsWith(window.location.origin);
|
||||
var isTOC = link.closest('[class*="toc"]') || link.closest('nav[aria-label*="table"]');
|
||||
var isAnchor = link.href.includes('#') && link.href.split('#')[0] === window.location.href.split('#')[0];
|
||||
|
||||
if (isTOC) {
|
||||
queueEvent('navigation', 'toc_click', {});
|
||||
} else if (isAnchor) {
|
||||
queueEvent('navigation', 'anchor_jump', {});
|
||||
} else {
|
||||
queueEvent('interaction', 'link_click', {
|
||||
isExternal: isExternal,
|
||||
domain: isExternal ? new URL(link.href).hostname : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleClick(e) {
|
||||
var now = Date.now();
|
||||
var x = e.clientX, y = e.clientY;
|
||||
|
||||
// Track click position for rage click detection
|
||||
state.clickPositions.push({ x: x, y: y, time: now });
|
||||
if (state.clickPositions.length > 10) state.clickPositions.shift();
|
||||
|
||||
// Rage click detection: 3+ clicks within 500ms in 50px radius
|
||||
var recentClicks = state.clickPositions.filter(function(c) {
|
||||
return now - c.time < 500 &&
|
||||
Math.abs(c.x - x) < 50 &&
|
||||
Math.abs(c.y - y) < 50;
|
||||
});
|
||||
if (recentClicks.length >= 3) {
|
||||
queueEvent('frustration', 'rage_click', { count: recentClicks.length });
|
||||
state.clickPositions = [];
|
||||
}
|
||||
|
||||
// Dead click detection: click on non-interactive element
|
||||
var tag = e.target.tagName;
|
||||
var isInteractive = state.interactiveTags[tag] ||
|
||||
e.target.onclick ||
|
||||
e.target.closest('a') ||
|
||||
e.target.closest('button') ||
|
||||
e.target.getAttribute('role') === 'button';
|
||||
|
||||
if (!isInteractive && e.target.closest('[data-slot="main"]')) {
|
||||
queueEvent('frustration', 'dead_click', {});
|
||||
}
|
||||
}
|
||||
|
||||
// Image viewing
|
||||
function initImageTracking() {
|
||||
var images = document.querySelectorAll('article img, [data-slot="main"] img');
|
||||
var observer = new IntersectionObserver(function(entries) {
|
||||
entries.forEach(function(entry) {
|
||||
if (entry.isIntersecting) {
|
||||
queueEvent('interaction', 'image_view', {
|
||||
alt: (entry.target.alt || '').substring(0, 50)
|
||||
});
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.5 });
|
||||
|
||||
images.forEach(function(img) { observer.observe(img); });
|
||||
}
|
||||
|
||||
// Code block interaction
|
||||
function initCodeBlockTracking() {
|
||||
var blocks = document.querySelectorAll('pre, code');
|
||||
blocks.forEach(function(block) {
|
||||
block.addEventListener('click', function() {
|
||||
queueEvent('interaction', 'code_interact', {});
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
function handleKeyDown(e) {
|
||||
// Ctrl+F / Cmd+F detection
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||
queueEvent('frustration', 'search_attempt', {});
|
||||
}
|
||||
// Ctrl+P / Cmd+P detection
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||
queueEvent('navigation', 'print_attempt', {});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMouseMove() {
|
||||
state.mouseMovements++;
|
||||
state.lastActivityTime = Date.now();
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Page Exit
|
||||
// =================================================================
|
||||
function handlePageExit() {
|
||||
if (state.sentExit) return;
|
||||
state.sentExit = true;
|
||||
|
||||
// Finalize active time
|
||||
if (state.isActive) {
|
||||
state.totalActiveTime += Date.now() - state.activeStart;
|
||||
}
|
||||
|
||||
// Calculate reading velocity
|
||||
var readTimeMinutes = state.totalActiveTime / 60000;
|
||||
var wpm = readTimeMinutes > 0 ? cfg.postWordCount / readTimeMinutes : 0;
|
||||
|
||||
// Send exit event
|
||||
queueEvent('scroll', 'page_exit', {
|
||||
exitScrollPercent: state.maxScrollPercent,
|
||||
totalTimeMs: Date.now() - state.pageStart,
|
||||
activeTimeMs: state.totalActiveTime,
|
||||
readingVelocityWpm: Math.round(wpm),
|
||||
maxScrollPercent: state.maxScrollPercent,
|
||||
directionChanges: state.directionChanges
|
||||
});
|
||||
|
||||
// Flush immediately on exit
|
||||
flushEvents();
|
||||
}
|
||||
|
||||
// =================================================================
|
||||
// Initialize
|
||||
// =================================================================
|
||||
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
document.addEventListener('selectionchange', handleTextSelection);
|
||||
document.addEventListener('copy', handleCopy);
|
||||
document.addEventListener('click', handleClick);
|
||||
document.addEventListener('click', handleLinkClick);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||
document.addEventListener('touchstart', handleActivity, { passive: true });
|
||||
window.addEventListener('beforeunload', handlePageExit);
|
||||
window.addEventListener('pagehide', handlePageExit);
|
||||
|
||||
// Initialize observers
|
||||
initImageTracking();
|
||||
initCodeBlockTracking();
|
||||
|
||||
// Initial scroll check
|
||||
setTimeout(handleScroll, 100);
|
||||
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,961 +0,0 @@
|
||||
package bn
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// resolveMediaURL converts media: prefixed URLs to proper /media/ paths
|
||||
// e.g., "media:abc/image.webp" → "/media/abc/image.webp"
|
||||
func resolveMediaURL(url string) string {
|
||||
if strings.HasPrefix(url, "media:") {
|
||||
return "/media/" + strings.TrimPrefix(url, "media:")
|
||||
}
|
||||
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
|
||||
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
|
||||
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
|
||||
Branding BrandingData
|
||||
AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing
|
||||
Toolbar ToolbarData
|
||||
LLMsTxtEnabled bool // Whether llms.txt is enabled for AI content discovery
|
||||
RSSFeedURL string // URL to the RSS feed (e.g., "/rss"), empty to disable
|
||||
RSSFeedTitle string // Feed title for discovery link
|
||||
}
|
||||
|
||||
// PageMeta contains page-level SEO meta data (overrides site defaults)
|
||||
type PageMeta struct {
|
||||
MetaTitle string // Custom SEO title (overrides page title)
|
||||
MetaDescription string // Page-specific meta description
|
||||
OGTitle string // Open Graph title
|
||||
OGDescription string // Open Graph description
|
||||
OGImage string // Open Graph image URL
|
||||
TwitterTitle string // Twitter card title
|
||||
TwitterDescription string // Twitter card description
|
||||
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
|
||||
type HeadData struct {
|
||||
Title string
|
||||
Settings SiteSettingsData
|
||||
PageMeta PageMeta // Page-level SEO overrides
|
||||
ThemeCSS string
|
||||
ThemeMode string // "light", "dark", or "system"
|
||||
PluginStyles []string // Additional stylesheet URLs
|
||||
StructuredData string // JSON-LD structured data
|
||||
CSSHash string // Cache-busting hash for styles.css
|
||||
PageviewNonce string // Unique nonce for pageview deduplication
|
||||
EngagementConfig EngagementConfig // Engagement tracking config (for blog posts)
|
||||
}
|
||||
|
||||
// ParseEngagementConfig extracts engagement config from the document map
|
||||
func ParseEngagementConfig(doc map[string]any) EngagementConfig {
|
||||
config := EngagementConfig{}
|
||||
|
||||
engData, ok := doc["engagement_config"].(map[string]any)
|
||||
if !ok {
|
||||
return config
|
||||
}
|
||||
|
||||
if v, ok := engData["page_path"].(string); ok {
|
||||
config.PagePath = v
|
||||
}
|
||||
if v, ok := engData["page_id"].(string); ok {
|
||||
config.PageID = v
|
||||
}
|
||||
if v, ok := engData["post_word_count"].(int); ok {
|
||||
config.PostWordCount = v
|
||||
}
|
||||
if v, ok := engData["session_id"].(string); ok {
|
||||
config.SessionID = v
|
||||
}
|
||||
if v, ok := engData["visitor_hash"].(string); ok {
|
||||
config.VisitorHash = v
|
||||
}
|
||||
if v, ok := engData["is_post"].(bool); ok {
|
||||
config.IsPost = v
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// ParseSiteSettings extracts site settings from the document map
|
||||
func ParseSiteSettings(doc map[string]any) SiteSettingsData {
|
||||
settings := SiteSettingsData{}
|
||||
|
||||
siteData, ok := doc["site_settings"].(map[string]any)
|
||||
if !ok {
|
||||
return settings
|
||||
}
|
||||
|
||||
if v, ok := siteData["title"].(string); ok {
|
||||
settings.Title = v
|
||||
}
|
||||
if v, ok := siteData["description"].(string); ok {
|
||||
settings.Description = v
|
||||
}
|
||||
if v, ok := siteData["favicon"].(string); ok {
|
||||
settings.Favicon = v
|
||||
}
|
||||
if v, ok := siteData["logo"].(string); ok {
|
||||
settings.Logo = v
|
||||
}
|
||||
if v, ok := siteData["logo_alt"].(string); ok {
|
||||
settings.LogoAlt = v
|
||||
}
|
||||
if v, ok := siteData["apple_touch_icon"].(string); ok {
|
||||
settings.AppleTouchIcon = v
|
||||
}
|
||||
|
||||
// SEO defaults
|
||||
if seoData, ok := siteData["seo_defaults"].(map[string]any); ok {
|
||||
if v, ok := seoData["meta_description"].(string); ok {
|
||||
settings.MetaDescription = v
|
||||
}
|
||||
if v, ok := seoData["default_og_image"].(string); ok {
|
||||
settings.DefaultOGImage = v
|
||||
}
|
||||
if v, ok := seoData["twitter_handle"].(string); ok {
|
||||
settings.TwitterHandle = v
|
||||
}
|
||||
if v, ok := seoData["og_site_name"].(string); ok {
|
||||
settings.OGSiteName = v
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
legacyHeadScripts = v
|
||||
}
|
||||
if v, ok := analyticsData["custom_body_scripts"].(string); ok {
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Branding (generated favicons)
|
||||
if brandingData, ok := siteData["branding"].(map[string]any); ok {
|
||||
if v, ok := brandingData["is_generated"].(bool); ok {
|
||||
settings.Branding.IsGenerated = v
|
||||
}
|
||||
if v, ok := brandingData["favicon_ico"].(string); ok {
|
||||
settings.Branding.FaviconICO = v
|
||||
}
|
||||
if v, ok := brandingData["favicon_16"].(string); ok {
|
||||
settings.Branding.Favicon16 = v
|
||||
}
|
||||
if v, ok := brandingData["favicon_32"].(string); ok {
|
||||
settings.Branding.Favicon32 = v
|
||||
}
|
||||
if v, ok := brandingData["apple_touch_icon"].(string); ok {
|
||||
settings.Branding.AppleTouchIcon = v
|
||||
}
|
||||
if v, ok := brandingData["android_192"].(string); ok {
|
||||
settings.Branding.Android192 = v
|
||||
}
|
||||
if v, ok := brandingData["android_512"].(string); ok {
|
||||
settings.Branding.Android512 = v
|
||||
}
|
||||
if v, ok := brandingData["maskable_512"].(string); ok {
|
||||
settings.Branding.Maskable512 = v
|
||||
}
|
||||
if v, ok := brandingData["master_1024"].(string); ok {
|
||||
settings.Branding.Master1024 = v
|
||||
}
|
||||
if v, ok := brandingData["manifest_url"].(string); ok {
|
||||
settings.Branding.ManifestURL = v
|
||||
}
|
||||
if v, ok := brandingData["theme_color"].(string); ok {
|
||||
settings.Branding.ThemeColor = v
|
||||
}
|
||||
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)
|
||||
if v, ok := siteData["admin_bypass_mode"].(string); ok {
|
||||
settings.AdminBypassMode = v
|
||||
}
|
||||
|
||||
// Admin editor toolbar
|
||||
if toolbarData, ok := siteData["toolbar"].(map[string]any); ok {
|
||||
settings.Toolbar = ParseToolbarData(toolbarData)
|
||||
}
|
||||
|
||||
// AI Optimization settings
|
||||
if aiOpt, ok := siteData["aiOptimization"].(map[string]any); ok {
|
||||
if v, ok := aiOpt["llmsTxtEnabled"].(bool); ok {
|
||||
settings.LLMsTxtEnabled = v
|
||||
}
|
||||
}
|
||||
|
||||
// RSS feed auto-discovery (injected by page handler from system page)
|
||||
if v, ok := siteData["rss_feed_url"].(string); ok {
|
||||
settings.RSSFeedURL = v
|
||||
}
|
||||
if v, ok := siteData["rss_feed_title"].(string); ok {
|
||||
settings.RSSFeedTitle = v
|
||||
}
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
// ParsePageMeta extracts page-level SEO metadata from the document map
|
||||
// These fields override site-level defaults when set
|
||||
func ParsePageMeta(doc map[string]any) PageMeta {
|
||||
meta := PageMeta{}
|
||||
|
||||
// Page-level SEO fields are stored directly in the document root
|
||||
// (matching frontend PageInfo type in web/src/store/editor.ts)
|
||||
if v, ok := doc["metaTitle"].(string); ok {
|
||||
meta.MetaTitle = v
|
||||
}
|
||||
if v, ok := doc["metaDescription"].(string); ok {
|
||||
meta.MetaDescription = v
|
||||
}
|
||||
if v, ok := doc["ogTitle"].(string); ok {
|
||||
meta.OGTitle = v
|
||||
}
|
||||
if v, ok := doc["ogDescription"].(string); ok {
|
||||
meta.OGDescription = v
|
||||
}
|
||||
if v, ok := doc["ogImage"].(string); ok {
|
||||
meta.OGImage = v
|
||||
}
|
||||
if v, ok := doc["twitterTitle"].(string); ok {
|
||||
meta.TwitterTitle = v
|
||||
}
|
||||
if v, ok := doc["twitterDescription"].(string); ok {
|
||||
meta.TwitterDescription = v
|
||||
}
|
||||
if v, ok := doc["twitterImage"].(string); ok {
|
||||
meta.TwitterImage = v
|
||||
}
|
||||
if v, ok := doc["canonicalUrl"].(string); ok {
|
||||
meta.CanonicalURL = v
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// ParseToolbarData extracts toolbar data from the document map
|
||||
func ParseToolbarData(data map[string]any) ToolbarData {
|
||||
toolbar := ToolbarData{}
|
||||
|
||||
if v, ok := data["enabled"].(bool); ok {
|
||||
toolbar.Enabled = v
|
||||
}
|
||||
if v, ok := data["page_id"].(string); ok {
|
||||
if id, err := uuid.Parse(v); err == nil {
|
||||
toolbar.PageID = id
|
||||
}
|
||||
}
|
||||
if v, ok := data["page_slug"].(string); ok {
|
||||
toolbar.PageSlug = v
|
||||
}
|
||||
if v, ok := data["page_title"].(string); ok {
|
||||
toolbar.PageTitle = v
|
||||
}
|
||||
if v, ok := data["post_type"].(string); ok {
|
||||
toolbar.PostType = v
|
||||
}
|
||||
if v, ok := data["status"].(string); ok {
|
||||
toolbar.Status = v
|
||||
}
|
||||
if v, ok := data["has_unpublished_changes"].(bool); ok {
|
||||
toolbar.HasUnpublishedChanges = v
|
||||
}
|
||||
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
|
||||
}
|
||||
if v, ok := data["template_name"].(string); ok {
|
||||
toolbar.TemplateName = v
|
||||
}
|
||||
if v, ok := data["author_name"].(string); ok {
|
||||
toolbar.AuthorName = v
|
||||
}
|
||||
if v, ok := data["author_slug"].(string); ok {
|
||||
toolbar.AuthorSlug = v
|
||||
}
|
||||
if v, ok := data["last_modified"].(time.Time); ok {
|
||||
toolbar.LastModified = v
|
||||
}
|
||||
if v, ok := data["edit_url"].(string); ok {
|
||||
toolbar.EditURL = v
|
||||
}
|
||||
if v, ok := data["settings_url"].(string); ok {
|
||||
toolbar.SettingsURL = v
|
||||
}
|
||||
if v, ok := data["history_url"].(string); ok {
|
||||
toolbar.HistoryURL = v
|
||||
}
|
||||
if v, ok := data["analytics_url"].(string); ok {
|
||||
toolbar.AnalyticsURL = v
|
||||
}
|
||||
|
||||
// Analytics snapshot
|
||||
if v, ok := data["today_pageviews"].(int64); ok {
|
||||
toolbar.TodayPageviews = v
|
||||
}
|
||||
if v, ok := data["pageviews_trend"].(string); ok {
|
||||
toolbar.PageviewsTrend = v
|
||||
}
|
||||
if v, ok := data["trend_percent"].(int); ok {
|
||||
toolbar.TrendPercent = v
|
||||
}
|
||||
|
||||
// Blog-specific fields
|
||||
if v, ok := data["reading_time"].(int); ok {
|
||||
toolbar.ReadingTime = v
|
||||
}
|
||||
if v, ok := data["word_count"].(int); ok {
|
||||
toolbar.WordCount = v
|
||||
}
|
||||
if v, ok := data["category_count"].(int); ok {
|
||||
toolbar.CategoryCount = v
|
||||
}
|
||||
|
||||
return toolbar
|
||||
}
|
||||
|
||||
// EffectiveTitle returns the title to use in the <title> tag.
|
||||
// Priority: PageMeta.MetaTitle → Page Title | Site Name → Page Title
|
||||
func (d HeadData) EffectiveTitle() string {
|
||||
if d.PageMeta.MetaTitle != "" {
|
||||
return d.PageMeta.MetaTitle
|
||||
}
|
||||
if d.Settings.OGSiteName != "" && d.Title != "" {
|
||||
return d.Title + " | " + d.Settings.OGSiteName
|
||||
}
|
||||
return d.Title
|
||||
}
|
||||
|
||||
// Head renders the complete <head> element with site settings and plugin extensions.
|
||||
templ Head(data HeadData) {
|
||||
@recordValidationCall(ctx, "Head")
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>{ data.EffectiveTitle() }</title>
|
||||
if data.CSSHash != "" {
|
||||
<link rel="stylesheet" href={ "/data/styles/styles.css?v=" + data.CSSHash }/>
|
||||
} else {
|
||||
<link rel="stylesheet" href="/data/styles/styles.css"/>
|
||||
}
|
||||
@themeInitScript(data.ThemeMode)
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
if data.Settings.Branding.IsGenerated {
|
||||
// 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 != "" {
|
||||
<link rel="icon" href={ data.Settings.Favicon }/>
|
||||
}
|
||||
if data.Settings.AppleTouchIcon != "" {
|
||||
<link rel="apple-touch-icon" href={ data.Settings.AppleTouchIcon }/>
|
||||
}
|
||||
}
|
||||
// === SEO META TAGS ===
|
||||
// Fallback chain: Page-level → Site defaults
|
||||
|
||||
// Meta description: Page override → Site default
|
||||
if data.PageMeta.MetaDescription != "" {
|
||||
<meta name="description" content={ data.PageMeta.MetaDescription }/>
|
||||
} else if data.Settings.MetaDescription != "" {
|
||||
<meta name="description" content={ data.Settings.MetaDescription }/>
|
||||
}
|
||||
|
||||
// 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)
|
||||
if data.PageMeta.RobotsDirective != "" {
|
||||
<meta name="robots" content={ data.PageMeta.RobotsDirective }/>
|
||||
}
|
||||
|
||||
// === OPEN GRAPH META TAGS ===
|
||||
// og:site_name is always site-level
|
||||
if data.Settings.OGSiteName != "" {
|
||||
<meta property="og:site_name" content={ data.Settings.OGSiteName }/>
|
||||
}
|
||||
|
||||
// og:title: Page OG → Page Meta Title → Page Title
|
||||
if data.PageMeta.OGTitle != "" {
|
||||
<meta property="og:title" content={ data.PageMeta.OGTitle }/>
|
||||
} else if data.PageMeta.MetaTitle != "" {
|
||||
<meta property="og:title" content={ data.PageMeta.MetaTitle }/>
|
||||
} else if data.Title != "" {
|
||||
<meta property="og:title" content={ data.Title }/>
|
||||
}
|
||||
|
||||
// og:description: Page OG → Page Meta Description → Site default
|
||||
if data.PageMeta.OGDescription != "" {
|
||||
<meta property="og:description" content={ data.PageMeta.OGDescription }/>
|
||||
} else if data.PageMeta.MetaDescription != "" {
|
||||
<meta property="og:description" content={ data.PageMeta.MetaDescription }/>
|
||||
} else if data.Settings.MetaDescription != "" {
|
||||
<meta property="og:description" content={ data.Settings.MetaDescription }/>
|
||||
}
|
||||
|
||||
// og:image: Page OG → Site default (resolve media: URLs)
|
||||
if data.PageMeta.OGImage != "" {
|
||||
<meta property="og:image" content={ resolveMediaURL(data.PageMeta.OGImage) }/>
|
||||
} else if data.Settings.DefaultOGImage != "" {
|
||||
<meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
|
||||
}
|
||||
|
||||
// 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 != "" {
|
||||
<meta name="twitter:site" content={ data.Settings.TwitterHandle }/>
|
||||
}
|
||||
<meta name="twitter:card" content="summary_large_image"/>
|
||||
|
||||
// twitter:title: Page Twitter → Page OG → Page Meta Title → Page Title
|
||||
if data.PageMeta.TwitterTitle != "" {
|
||||
<meta name="twitter:title" content={ data.PageMeta.TwitterTitle }/>
|
||||
} else if data.PageMeta.OGTitle != "" {
|
||||
<meta name="twitter:title" content={ data.PageMeta.OGTitle }/>
|
||||
} else if data.PageMeta.MetaTitle != "" {
|
||||
<meta name="twitter:title" content={ data.PageMeta.MetaTitle }/>
|
||||
} else if data.Title != "" {
|
||||
<meta name="twitter:title" content={ data.Title }/>
|
||||
}
|
||||
|
||||
// twitter:description: Page Twitter → Page OG → Page Meta Description → Site default
|
||||
if data.PageMeta.TwitterDescription != "" {
|
||||
<meta name="twitter:description" content={ data.PageMeta.TwitterDescription }/>
|
||||
} else if data.PageMeta.OGDescription != "" {
|
||||
<meta name="twitter:description" content={ data.PageMeta.OGDescription }/>
|
||||
} else if data.PageMeta.MetaDescription != "" {
|
||||
<meta name="twitter:description" content={ data.PageMeta.MetaDescription }/>
|
||||
} else if data.Settings.MetaDescription != "" {
|
||||
<meta name="twitter:description" content={ data.Settings.MetaDescription }/>
|
||||
}
|
||||
|
||||
// twitter:image: Page Twitter → Page OG → Site default (resolve media: URLs)
|
||||
if data.PageMeta.TwitterImage != "" {
|
||||
<meta name="twitter:image" content={ resolveMediaURL(data.PageMeta.TwitterImage) }/>
|
||||
} else if data.PageMeta.OGImage != "" {
|
||||
<meta name="twitter:image" content={ resolveMediaURL(data.PageMeta.OGImage) }/>
|
||||
} else if data.Settings.DefaultOGImage != "" {
|
||||
<meta name="twitter:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
|
||||
}
|
||||
|
||||
// === AI CONTENT DISCOVERY ===
|
||||
// llms.txt meta tag for AI crawlers (if enabled)
|
||||
if data.Settings.LLMsTxtEnabled {
|
||||
<link rel="ai-content" type="text/plain" href="/llms.txt"/>
|
||||
<meta name="llms" content="/llms.txt"/>
|
||||
}
|
||||
|
||||
// === RSS/ATOM FEED DISCOVERY ===
|
||||
if data.Settings.RSSFeedURL != "" {
|
||||
<link rel="alternate" type="application/rss+xml" title={ data.Settings.RSSFeedTitle + " (RSS)" } href={ data.Settings.RSSFeedURL }/>
|
||||
<link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/>
|
||||
}
|
||||
|
||||
if data.Settings.GoogleAnalyticsID != "" && (data.Settings.GoogleAnalyticsEnabled || !data.Settings.GoogleAnalyticsExplicitlySet) {
|
||||
<script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script>
|
||||
@googleAnalyticsScript(data.Settings.GoogleAnalyticsID)
|
||||
}
|
||||
// Built-in analytics tracking (cookie-less) with nonce for server-side deduplication
|
||||
@analyticsScript(data.PageviewNonce)
|
||||
// Blog post engagement tracking (only rendered for posts with engagement config)
|
||||
@EngagementScript(data.EngagementConfig)
|
||||
for _, style := range data.PluginStyles {
|
||||
<link rel="stylesheet" href={ VersionedAssetURL(style) }/>
|
||||
}
|
||||
// Theme CSS injected AFTER plugin styles to take precedence
|
||||
if data.ThemeCSS != "" {
|
||||
@themeStyle(data.ThemeCSS)
|
||||
}
|
||||
for _, script := range data.Settings.CustomScripts {
|
||||
if script.Enabled && script.Placement == "head" {
|
||||
@templ.Raw(script.Code)
|
||||
}
|
||||
}
|
||||
if data.StructuredData != "" {
|
||||
@structuredDataScript(data.StructuredData)
|
||||
}
|
||||
{ children... }
|
||||
</head>
|
||||
}
|
||||
|
||||
// structuredDataScript renders JSON-LD structured data
|
||||
func structuredDataScript(jsonLD string) templ.Component {
|
||||
return templ.Raw(`<script type="application/ld+json">` + jsonLD + `</script>`)
|
||||
}
|
||||
|
||||
// AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode
|
||||
// This should be rendered at the very start of the <body> to push down all content
|
||||
templ AdminBypassBanner(settings SiteSettingsData) {
|
||||
if settings.AdminBypassMode != "" {
|
||||
<div class="bg-warning text-warning-foreground px-4 py-2 text-center text-sm font-medium relative z-[9999]">
|
||||
<div class="max-w-7xl mx-auto flex items-center justify-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
if settings.AdminBypassMode == "maintenance" {
|
||||
<span>
|
||||
<strong>Maintenance Mode Active</strong> — You're seeing the normal site because you're logged in as admin.
|
||||
<a href="/admin/settings?tab=status" class="underline hover:no-underline">Manage</a>
|
||||
</span>
|
||||
} else if settings.AdminBypassMode == "coming_soon" {
|
||||
<span>
|
||||
<strong>Coming Soon Mode Active</strong> — You're seeing the normal site because you're logged in as admin.
|
||||
<a href="/admin/settings?tab=status" class="underline hover:no-underline">Manage</a>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// BodyEnd renders custom scripts and admin toolbar before </body>
|
||||
templ BodyEnd(settings SiteSettingsData) {
|
||||
@recordValidationCall(ctx, "BodyEnd")
|
||||
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)
|
||||
{ children... }
|
||||
}
|
||||
|
||||
// themeStyle renders the theme CSS variables
|
||||
func themeStyleComponent(css string) templ.Component {
|
||||
return templ.Raw(`<style id="theme-variables">` + css + `</style>`)
|
||||
}
|
||||
|
||||
templ themeStyle(css string) {
|
||||
@themeStyleComponent(css)
|
||||
}
|
||||
|
||||
// googleAnalyticsScript renders the GA4 inline script
|
||||
func googleAnalyticsScript(gaID string) templ.Component {
|
||||
return templ.Raw(`<script>window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '` + gaID + `');</script>`)
|
||||
}
|
||||
|
||||
// analyticsScript renders the built-in analytics tracking script (cookie-less)
|
||||
// nonce is used for deduplication with server-side tracking
|
||||
templ analyticsScript(nonce string) {
|
||||
<script data-nonce={ nonce }>
|
||||
(function(){
|
||||
// Don't track admin pages
|
||||
if(location.pathname.startsWith('/admin'))return;
|
||||
|
||||
// CRITICAL: Capture nonce immediately while document.currentScript is valid
|
||||
// document.currentScript is only available during script parsing, not later
|
||||
var _nonce = document.currentScript ? document.currentScript.getAttribute('data-nonce') : '';
|
||||
|
||||
// Persistent session ID (survives page navigation within session)
|
||||
var sid=sessionStorage.getItem('_bn_sid');
|
||||
if(!sid){sid=crypto.randomUUID();sessionStorage.setItem('_bn_sid',sid);}
|
||||
|
||||
// ============================================================
|
||||
// Advanced Bot Detection - Client-Side Signals
|
||||
// See docs/BOT_DETECTION.md for full documentation
|
||||
// ============================================================
|
||||
var botSignals={},botScore=0;
|
||||
try{
|
||||
// 1. WebDriver property (30 points) - automation frameworks set this
|
||||
botSignals.webdriver=!!navigator.webdriver;
|
||||
if(botSignals.webdriver)botScore+=30;
|
||||
|
||||
// 2. CDP Detection (25 points) - detects Puppeteer/Playwright/Selenium
|
||||
botSignals.cdp=false;
|
||||
try{
|
||||
var e=new Error();
|
||||
Object.defineProperty(e,'stack',{get:function(){botSignals.cdp=true;}});
|
||||
console.debug(e);
|
||||
if(botSignals.cdp)botScore+=25;
|
||||
}catch(x){}
|
||||
|
||||
// 3. Plugin count (15 points) - headless often has no plugins
|
||||
botSignals.plugins=navigator.plugins?navigator.plugins.length:0;
|
||||
if(botSignals.plugins===0)botScore+=15;
|
||||
|
||||
// 4. Languages (15 points) - headless often has empty languages
|
||||
botSignals.languages=navigator.languages?navigator.languages.length:0;
|
||||
if(botSignals.languages===0)botScore+=15;
|
||||
|
||||
// 5. Chrome runtime (10 points) - real Chrome has chrome.runtime
|
||||
botSignals.chromeRuntime=!!(window.chrome&&window.chrome.runtime);
|
||||
if(window.chrome&&!window.chrome.runtime)botScore+=10;
|
||||
|
||||
// 6. WebGL renderer (20 points) - headless uses SwiftShader/llvmpipe
|
||||
try{
|
||||
var c=document.createElement('canvas');
|
||||
var gl=c.getContext('webgl')||c.getContext('experimental-webgl');
|
||||
if(gl){
|
||||
var dbg=gl.getExtension('WEBGL_debug_renderer_info');
|
||||
if(dbg){
|
||||
botSignals.webglRenderer=gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)||'';
|
||||
var r=botSignals.webglRenderer.toLowerCase();
|
||||
if(r.includes('swiftshader')||r.includes('llvmpipe')||r.includes('software'))botScore+=20;
|
||||
}
|
||||
}
|
||||
}catch(x){}
|
||||
|
||||
// 7. Permission anomalies (15 points) - inconsistent API states
|
||||
if(window.Notification&&navigator.permissions){
|
||||
navigator.permissions.query({name:'notifications'}).then(function(p){
|
||||
if(p.state==='denied'&&Notification.permission==='default'){
|
||||
botSignals.permissionAnomaly=true;
|
||||
botScore+=15;
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
// 8. Headless in Client Hints (20 points)
|
||||
if(navigator.userAgentData&&navigator.userAgentData.getHighEntropyValues){
|
||||
navigator.userAgentData.getHighEntropyValues(['fullVersionList','platform']).then(function(h){
|
||||
if(h.brands&&h.brands.some(function(b){return b.brand.toLowerCase().includes('headless');})){
|
||||
botSignals.headlessHints=true;
|
||||
botScore+=20;
|
||||
}
|
||||
}).catch(function(){});
|
||||
}
|
||||
|
||||
// 9. Automation variables (10 points each)
|
||||
if(window._phantom||window.__nightmare||window.callPhantom){botSignals.phantomjs=true;botScore+=10;}
|
||||
if(window.__selenium_unwrapped||window.__webdriver_evaluate||document.__selenium_evaluate){botSignals.selenium=true;botScore+=10;}
|
||||
if(window.__fxdriver_evaluate||window.__webdriver_script_fn){botSignals.selenium=true;botScore+=10;}
|
||||
|
||||
// 10. Broken image dimensions (10 points) - headless often fails to render
|
||||
try{
|
||||
var img=new Image();
|
||||
img.src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||
if(img.height===0){botSignals.brokenImage=true;botScore+=10;}
|
||||
}catch(x){}
|
||||
|
||||
// Cap at 100
|
||||
botScore=Math.min(botScore,100);
|
||||
botSignals.score=botScore;
|
||||
}catch(x){botSignals.error=x.message;}
|
||||
|
||||
// ============================================================
|
||||
// Web Vitals collection
|
||||
// ============================================================
|
||||
var _lcp=0,_fcp=0,_cls=0,_ttfb=0;
|
||||
if('PerformanceObserver'in window){
|
||||
try{
|
||||
new PerformanceObserver(function(l){var e=l.getEntries().pop();if(e)_lcp=Math.round(e.startTime);}).observe({type:'largest-contentful-paint',buffered:true});
|
||||
new PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(e.name==='first-contentful-paint')_fcp=Math.round(e.startTime);});}).observe({type:'paint',buffered:true});
|
||||
new PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(!e.hadRecentInput)_cls+=e.value;});}).observe({type:'layout-shift',buffered:true});
|
||||
}catch(e){}
|
||||
}
|
||||
try{var nav=performance.getEntriesByType('navigation')[0];if(nav)_ttfb=Math.round(nav.responseStart);}catch(e){}
|
||||
|
||||
// Scroll depth tracking
|
||||
var _maxScroll=0;
|
||||
function updateScroll(){
|
||||
var h=document.documentElement.scrollHeight-window.innerHeight;
|
||||
if(h>0){var pct=Math.round((window.scrollY/h)*100);if(pct>_maxScroll)_maxScroll=pct;}
|
||||
}
|
||||
window.addEventListener('scroll',updateScroll,{passive:true});
|
||||
|
||||
// Send beacon with fallback to fetch
|
||||
function send(url,data){
|
||||
var payload=JSON.stringify(data);
|
||||
if(!navigator.sendBeacon(url,payload)){
|
||||
fetch(url,{method:'POST',body:payload,keepalive:true}).catch(function(){});
|
||||
}
|
||||
}
|
||||
|
||||
// Track pageview with server-side deduplication nonce
|
||||
function t(){
|
||||
var d={
|
||||
p:location.pathname+location.search,
|
||||
r:document.referrer,
|
||||
sw:screen.width,
|
||||
sh:screen.height,
|
||||
vw:window.innerWidth,
|
||||
vh:window.innerHeight,
|
||||
pr:window.devicePixelRatio||1,
|
||||
tz:new Date().getTimezoneOffset(),
|
||||
cs:window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light',
|
||||
// Bot detection signals
|
||||
bs:botSignals,
|
||||
bsc:botScore
|
||||
};
|
||||
if(navigator.connection){
|
||||
d.ct=navigator.connection.effectiveType||'';
|
||||
d.cd=navigator.connection.downlink||0;
|
||||
}
|
||||
// Use the nonce captured at parse time for server-side deduplication
|
||||
if(_nonce)d.n=_nonce;
|
||||
var u=new URLSearchParams(location.search);
|
||||
if(u.get('s'))d.st=u.get('s');
|
||||
send('/api/track',d);
|
||||
}
|
||||
|
||||
// Exit beacon with time on page and web vitals
|
||||
var pageStart=Date.now();
|
||||
function sendExit(){
|
||||
send('/api/track/exit',{
|
||||
sid:sid,
|
||||
top:Date.now()-pageStart,
|
||||
sd:_maxScroll,
|
||||
lcp:_lcp,
|
||||
fcp:_fcp,
|
||||
cls:Math.round(_cls*1000)/1000,
|
||||
ttfb:_ttfb
|
||||
});
|
||||
}
|
||||
document.addEventListener('visibilitychange',function(){
|
||||
if(document.visibilityState==='hidden')sendExit();
|
||||
});
|
||||
|
||||
// Track on load
|
||||
if(document.readyState==='complete'){t();}else{window.addEventListener('load',t);}
|
||||
})();
|
||||
</script>
|
||||
}
|
||||
|
||||
func themeInitScript(themeMode string) templ.Component {
|
||||
switch themeMode {
|
||||
case "light", "dark", "system":
|
||||
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>
|
||||
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 + `';
|
||||
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
|
||||
document.documentElement.classList.toggle('dark',t==='dark');
|
||||
return t;
|
||||
};
|
||||
window.bnApplyTheme();
|
||||
</script>`)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,686 +0,0 @@
|
||||
package bn
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ToolbarData contains data for the admin editor toolbar
|
||||
type ToolbarData struct {
|
||||
Enabled bool
|
||||
PageID uuid.UUID
|
||||
PageSlug string
|
||||
PageTitle string
|
||||
PostType string // "page", "post", "master", "system"
|
||||
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
|
||||
AuthorName string
|
||||
LastModified time.Time
|
||||
EditURL string
|
||||
SettingsURL string
|
||||
HistoryURL string
|
||||
AnalyticsURL string
|
||||
|
||||
// Analytics snapshot
|
||||
TodayPageviews int64
|
||||
PageviewsTrend string // "up", "down", "flat"
|
||||
TrendPercent int
|
||||
|
||||
// Blog-specific fields
|
||||
ReadingTime int // minutes
|
||||
WordCount int
|
||||
CategoryCount int
|
||||
AuthorSlug string
|
||||
}
|
||||
|
||||
// StatusBadgeClass returns the Tailwind classes for the status badge
|
||||
func (t ToolbarData) StatusBadgeClass() string {
|
||||
switch t.Status {
|
||||
case "published":
|
||||
return "bg-success text-success-foreground"
|
||||
case "scheduled":
|
||||
return "bg-info text-info-foreground"
|
||||
default:
|
||||
return "bg-warning text-warning-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
// StatusLabel returns a human-readable status label
|
||||
func (t ToolbarData) StatusLabel() string {
|
||||
switch t.Status {
|
||||
case "published":
|
||||
return "Published"
|
||||
case "scheduled":
|
||||
return "Scheduled"
|
||||
default:
|
||||
return "Draft"
|
||||
}
|
||||
}
|
||||
|
||||
// PostTypeLabel returns a human-readable post type label
|
||||
func (t ToolbarData) PostTypeLabel() string {
|
||||
switch t.PostType {
|
||||
case "post":
|
||||
return "Post"
|
||||
case "master":
|
||||
return "Master"
|
||||
case "system":
|
||||
return "System"
|
||||
default:
|
||||
return "Page"
|
||||
}
|
||||
}
|
||||
|
||||
// IsPreviewMode returns true if currently viewing draft/preview
|
||||
func (t ToolbarData) IsPreviewMode() bool {
|
||||
return t.PreviewMode == "draft"
|
||||
}
|
||||
|
||||
// CanPublish returns true if the publish button should be enabled
|
||||
func (t ToolbarData) CanPublish() bool {
|
||||
return t.HasUnpublishedChanges
|
||||
}
|
||||
|
||||
// LastModifiedFormatted returns the last modified time in a readable format
|
||||
func (t ToolbarData) LastModifiedFormatted() string {
|
||||
return t.LastModified.Format("Jan 2, 2006 3:04 PM")
|
||||
}
|
||||
|
||||
// IsTopPosition returns true if toolbar should be at top (for legacy compatibility)
|
||||
func (t ToolbarData) IsTopPosition() bool {
|
||||
return t.Position == "tl" || t.Position == "tc" || t.Position == "tr" || t.Position == ""
|
||||
}
|
||||
|
||||
// IsBottomPosition returns true if toolbar is at bottom
|
||||
func (t ToolbarData) IsBottomPosition() bool {
|
||||
return t.Position == "bl" || t.Position == "bc" || t.Position == "br"
|
||||
}
|
||||
|
||||
// IsLeftPosition returns true if toolbar is on the left side
|
||||
func (t ToolbarData) IsLeftPosition() bool {
|
||||
return t.Position == "tl" || t.Position == "bl"
|
||||
}
|
||||
|
||||
// IsRightPosition returns true if toolbar is on the right side
|
||||
func (t ToolbarData) IsRightPosition() bool {
|
||||
return t.Position == "tr" || t.Position == "br" || t.Position == ""
|
||||
}
|
||||
|
||||
// IsCenterPosition returns true if toolbar is centered
|
||||
func (t ToolbarData) IsCenterPosition() bool {
|
||||
return t.Position == "tc" || t.Position == "bc"
|
||||
}
|
||||
|
||||
// DropdownDirection returns "up" or "down" based on toolbar vertical position
|
||||
func (t ToolbarData) DropdownDirection() string {
|
||||
if t.IsBottomPosition() {
|
||||
return "up"
|
||||
}
|
||||
return "down"
|
||||
}
|
||||
|
||||
// DropdownAlign returns "left", "center", or "right" based on toolbar horizontal position
|
||||
func (t ToolbarData) DropdownAlign() string {
|
||||
if t.IsLeftPosition() {
|
||||
return "left"
|
||||
} else if t.IsCenterPosition() {
|
||||
return "center"
|
||||
}
|
||||
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 {
|
||||
case "tl":
|
||||
return "top-4 left-4"
|
||||
case "tc":
|
||||
return "top-4 left-1/2 -translate-x-1/2"
|
||||
case "bl":
|
||||
return "bottom-4 left-4"
|
||||
case "bc":
|
||||
return "bottom-4 left-1/2 -translate-x-1/2"
|
||||
case "br":
|
||||
return "bottom-4 right-4"
|
||||
default: // "tr" or empty
|
||||
return "top-4 right-4"
|
||||
}
|
||||
}
|
||||
|
||||
// IsBlogPost returns true if this is a blog post
|
||||
func (t ToolbarData) IsBlogPost() bool {
|
||||
return t.PostType == "post"
|
||||
}
|
||||
|
||||
// TrendIcon returns the trend icon for pageviews
|
||||
func (t ToolbarData) TrendIcon() string {
|
||||
switch t.PageviewsTrend {
|
||||
case "up":
|
||||
return "↑"
|
||||
case "down":
|
||||
return "↓"
|
||||
default:
|
||||
return "→"
|
||||
}
|
||||
}
|
||||
|
||||
// TrendColorClass returns the color class for the trend indicator
|
||||
func (t ToolbarData) TrendColorClass() string {
|
||||
switch t.PageviewsTrend {
|
||||
case "up":
|
||||
return "text-success"
|
||||
case "down":
|
||||
return "text-destructive"
|
||||
default:
|
||||
return "text-muted-foreground"
|
||||
}
|
||||
}
|
||||
|
||||
// AdminEditorToolbar renders the floating pill toolbar for admins on public pages
|
||||
// Uses inverted color scheme - dark on light backgrounds, light on dark backgrounds
|
||||
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(), data.EnterAnimationClass() }
|
||||
>
|
||||
<style>
|
||||
/* Toolbar: dark by default (for light pages), light when page has .dark class */
|
||||
.bn-toolbar {
|
||||
--bn-toolbar-bg: rgba(24, 24, 27, 0.95);
|
||||
--bn-toolbar-fg: #fafafa;
|
||||
--bn-toolbar-muted: #a1a1aa;
|
||||
--bn-toolbar-border: rgba(63, 63, 70, 0.8);
|
||||
--bn-toolbar-hover: rgba(63, 63, 70, 0.5);
|
||||
--bn-toolbar-primary-bg: #3b82f6;
|
||||
--bn-toolbar-primary-fg: #ffffff;
|
||||
background: var(--bn-toolbar-bg);
|
||||
color: var(--bn-toolbar-fg);
|
||||
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);
|
||||
--bn-toolbar-fg: #18181b;
|
||||
--bn-toolbar-muted: #71717a;
|
||||
--bn-toolbar-border: rgba(228, 228, 231, 0.8);
|
||||
--bn-toolbar-hover: rgba(228, 228, 231, 0.5);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.bn-toolbar .bn-toolbar-text { color: var(--bn-toolbar-fg); }
|
||||
.bn-toolbar .bn-toolbar-muted { color: var(--bn-toolbar-muted); }
|
||||
.bn-toolbar .bn-toolbar-divider { background: var(--bn-toolbar-border); }
|
||||
.bn-toolbar .bn-toolbar-hover:hover { background: var(--bn-toolbar-hover); }
|
||||
.bn-toolbar .bn-toolbar-primary {
|
||||
background: var(--bn-toolbar-primary-bg);
|
||||
color: var(--bn-toolbar-primary-fg);
|
||||
}
|
||||
.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);
|
||||
color: var(--bn-toolbar-fg);
|
||||
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);
|
||||
}
|
||||
.bn-toolbar-dropdown .bn-dd-border { border-color: var(--bn-toolbar-border); }
|
||||
.bn-toolbar-dropdown .bn-dd-text { color: var(--bn-toolbar-fg); }
|
||||
.bn-toolbar-dropdown .bn-dd-muted { color: var(--bn-toolbar-muted); }
|
||||
.bn-toolbar-dropdown .bn-dd-hover:hover { background: var(--bn-toolbar-hover); }
|
||||
.bn-toolbar-dropdown .bn-dd-link { color: var(--bn-toolbar-primary-bg); }
|
||||
.bn-toolbar-dropdown .bn-dd-link:hover { text-decoration: underline; }
|
||||
/* Dropdown positioning based on data attributes */
|
||||
.bn-dropdown-container { position: absolute; }
|
||||
.bn-dropdown-container[data-direction="up"] { bottom: 100%; margin-bottom: 0.5rem; }
|
||||
.bn-dropdown-container[data-direction="down"] { top: 100%; margin-top: 0.5rem; }
|
||||
.bn-dropdown-container[data-align="left"] { left: 0; }
|
||||
.bn-dropdown-container[data-align="right"] { right: 0; }
|
||||
.bn-dropdown-container[data-align="center"] { left: 50%; transform: translateX(-50%); }
|
||||
</style>
|
||||
<!-- Edit button -->
|
||||
<a
|
||||
href={ templ.SafeURL(data.EditURL) }
|
||||
class="bn-toolbar-primary inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-colors"
|
||||
title="Edit this page"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Edit</span>
|
||||
</a>
|
||||
<!-- Status indicator -->
|
||||
<div class="flex items-center gap-1.5 px-2" title={ data.StatusLabel() }>
|
||||
<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>
|
||||
<!-- Theme tester: cycles site default → forced light → forced dark.
|
||||
Per-tab override (sessionStorage), applied by bnApplyTheme in head.templ.
|
||||
Never touches the visitor bn-theme preference. -->
|
||||
<button
|
||||
type="button"
|
||||
id="bn-theme-tester"
|
||||
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors"
|
||||
onclick="bnCycleThemeTester()"
|
||||
title="Theme: site default — click to force light"
|
||||
>
|
||||
<span data-tt="auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
||||
<line x1="8" y1="21" x2="16" y2="21"></line>
|
||||
<line x1="12" y1="17" x2="12" y2="21"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<span data-tt="light" style="display:none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"></circle>
|
||||
<line x1="12" y1="1" x2="12" y2="3"></line>
|
||||
<line x1="12" y1="21" x2="12" y2="23"></line>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
||||
<line x1="1" y1="12" x2="3" y2="12"></line>
|
||||
<line x1="21" y1="12" x2="23" y2="12"></line>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
||||
</svg>
|
||||
</span>
|
||||
<span data-tt="dark" style="display:none">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
<span data-tt-label class="hidden sm:inline" style="display:none"></span>
|
||||
</button>
|
||||
<script>
|
||||
// Theme tester: per-tab light/dark override for admins. Lives inside
|
||||
// #bn-admin-toolbar so HTMX outerHTML swaps re-run the sync call.
|
||||
window.bnCycleThemeTester = function() {
|
||||
var cur = null;
|
||||
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
|
||||
var next = cur === 'light' ? 'dark' : (cur === 'dark' ? null : 'light');
|
||||
try {
|
||||
if (next) sessionStorage.setItem('bn-theme-override', next);
|
||||
else sessionStorage.removeItem('bn-theme-override');
|
||||
} catch (e) {}
|
||||
if (window.bnApplyTheme) {
|
||||
window.bnApplyTheme();
|
||||
} else if (next) {
|
||||
// Fallback for templates without the bn head script
|
||||
document.documentElement.classList.toggle('dark', next === 'dark');
|
||||
}
|
||||
window.bnSyncThemeTester();
|
||||
// Let darkmode-switcher blocks refresh their icons
|
||||
var mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
document.querySelectorAll('[data-bn-theme-toggle]').forEach(function(el) {
|
||||
el.dispatchEvent(new CustomEvent('bn:theme-changed', { detail: { mode: mode } }));
|
||||
});
|
||||
};
|
||||
window.bnSyncThemeTester = function() {
|
||||
var btn = document.getElementById('bn-theme-tester');
|
||||
if (!btn) return;
|
||||
var cur = null;
|
||||
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
|
||||
var state = (cur === 'light' || cur === 'dark') ? cur : 'auto';
|
||||
btn.querySelectorAll('[data-tt]').forEach(function(s) {
|
||||
s.style.display = s.getAttribute('data-tt') === state ? '' : 'none';
|
||||
});
|
||||
var lbl = btn.querySelector('[data-tt-label]');
|
||||
if (state === 'auto') {
|
||||
lbl.style.display = 'none';
|
||||
lbl.textContent = '';
|
||||
} else {
|
||||
lbl.style.display = '';
|
||||
lbl.textContent = state === 'light' ? 'Light' : 'Dark';
|
||||
}
|
||||
btn.classList.toggle('bn-theme-tester-active', state !== 'auto');
|
||||
btn.classList.toggle('bn-toolbar-muted', state === 'auto');
|
||||
btn.title = state === 'auto'
|
||||
? 'Theme: site default — click to force light'
|
||||
: (state === 'light'
|
||||
? 'Theme: forced light — click to force dark'
|
||||
: 'Theme: forced dark — click to reset to site default');
|
||||
};
|
||||
window.bnSyncThemeTester();
|
||||
</script>
|
||||
<!-- Blog-specific: Reading time -->
|
||||
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">
|
||||
<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 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
{ fmt.Sprintf("%d min", data.ReadingTime) }
|
||||
</div>
|
||||
}
|
||||
<!-- Publish button (if changes) -->
|
||||
if data.CanPublish() {
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-success hover:bg-success/90 text-success-foreground rounded-full transition-colors"
|
||||
hx-post={ fmt.Sprintf("/toolbar/publish/%s", data.PageID) }
|
||||
hx-target="#bn-admin-toolbar"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Publish this page?"
|
||||
title="Publish changes"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" 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">Publish</span>
|
||||
</button>
|
||||
}
|
||||
<!-- More menu -->
|
||||
<div class="relative" id="more-menu-container">
|
||||
<button
|
||||
type="button"
|
||||
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||
hx-get={ fmt.Sprintf("/toolbar/page-info/%s", data.PageID) }
|
||||
hx-target="#page-info-dropdown"
|
||||
hx-trigger="click"
|
||||
hx-swap="innerHTML"
|
||||
title="More options"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
id="page-info-dropdown"
|
||||
class="bn-dropdown-container"
|
||||
data-direction={ data.DropdownDirection() }
|
||||
data-align={ data.DropdownAlign() }
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Click outside to close dropdowns -->
|
||||
<script>
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('#more-menu-container')) {
|
||||
document.getElementById('page-info-dropdown').innerHTML = '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
}
|
||||
}
|
||||
|
||||
// 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">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
|
||||
</svg>
|
||||
<span class="bn-dd-text text-sm font-medium">{ fmt.Sprintf("%d", data.TodayPageviews) } views today</span>
|
||||
</div>
|
||||
if data.TrendPercent != 0 {
|
||||
<span class={ "text-xs font-medium", data.TrendColorClass() }>
|
||||
{ data.TrendIcon() } { fmt.Sprintf("%d%%", abs(data.TrendPercent)) }
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Blog-specific: Author & Reading time -->
|
||||
if data.IsBlogPost() {
|
||||
<div class="bn-dd-border px-3 py-2 border-b">
|
||||
<div class="flex items-center justify-between text-sm">
|
||||
if data.AuthorName != "" {
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
if data.AuthorSlug != "" {
|
||||
<a href={ templ.SafeURL("/author/" + data.AuthorSlug) } class="bn-dd-link">{ data.AuthorName }</a>
|
||||
} else {
|
||||
<span class="bn-dd-text">{ data.AuthorName }</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
if data.ReadingTime > 0 {
|
||||
<span class="bn-dd-muted text-xs">{ fmt.Sprintf("%d min read", data.ReadingTime) }</span>
|
||||
}
|
||||
</div>
|
||||
if data.WordCount > 0 {
|
||||
<div class="bn-dd-muted text-xs mt-1">{ fmt.Sprintf("%d words", data.WordCount) }</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Page info -->
|
||||
<div class="bn-dd-border bn-dd-muted px-3 py-2 border-b text-xs">
|
||||
<div class="flex justify-between">
|
||||
<span>Template</span>
|
||||
<span class="bn-dd-text">{ data.TemplateName }</span>
|
||||
</div>
|
||||
<div class="flex justify-between mt-1">
|
||||
<span>Modified</span>
|
||||
<span class="bn-dd-text">{ data.LastModifiedFormatted() }</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Quick actions -->
|
||||
<div class="py-1">
|
||||
<a href={ templ.SafeURL(data.SettingsURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Settings
|
||||
</a>
|
||||
<a href={ templ.SafeURL(data.HistoryURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
History
|
||||
</a>
|
||||
<a href={ templ.SafeURL(data.AnalyticsURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
|
||||
</svg>
|
||||
Analytics
|
||||
</a>
|
||||
</div>
|
||||
<!-- Sharing -->
|
||||
<div class="bn-dd-border border-t py-1">
|
||||
<button
|
||||
type="button"
|
||||
class="bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
|
||||
onclick={ copyPageURL(data.PageSlug) }
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"></path>
|
||||
</svg>
|
||||
Copy link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
|
||||
hx-post={ fmt.Sprintf("/toolbar/share-preview/%s", data.PageID) }
|
||||
hx-swap="innerHTML"
|
||||
hx-target="this"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z"></path>
|
||||
</svg>
|
||||
Share preview link
|
||||
</button>
|
||||
</div>
|
||||
<!-- Position selector -->
|
||||
<div class="bn-dd-border border-t px-3 py-2">
|
||||
<div class="bn-dd-muted text-xs mb-2">Move toolbar</div>
|
||||
<div class="grid grid-cols-3 gap-1">
|
||||
@positionButton(data.PageID, "tl", data.Position, "Top left")
|
||||
@positionButton(data.PageID, "tc", data.Position, "Top center")
|
||||
@positionButton(data.PageID, "tr", data.Position, "Top right")
|
||||
@positionButton(data.PageID, "bl", data.Position, "Bottom left")
|
||||
@positionButton(data.PageID, "bc", data.Position, "Bottom center")
|
||||
@positionButton(data.PageID, "br", data.Position, "Bottom right")
|
||||
</div>
|
||||
</div>
|
||||
<!-- Danger zone -->
|
||||
if data.HasUnpublishedChanges {
|
||||
<div class="bn-dd-border border-t py-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||
hx-post={ fmt.Sprintf("/toolbar/discard/%s", data.PageID) }
|
||||
hx-target="#bn-admin-toolbar"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Discard all unpublished changes? This cannot be undone."
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Discard changes
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// 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={ "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("bn-dd-dot-active", active), templ.KV("bn-dd-dot", !active) }></span>
|
||||
</button>
|
||||
}
|
||||
|
||||
// abs returns the absolute value of an integer
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// copyPageURL generates the script to copy page URL
|
||||
script copyPageURL(slug string) {
|
||||
const url = window.location.origin + slug;
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
// Show brief feedback - could enhance with toast later
|
||||
const btn = event.currentTarget;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> Copied!';
|
||||
setTimeout(() => { btn.innerHTML = originalText; }, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// togglePreviewMode toggles between preview (draft) and published mode via URL
|
||||
script togglePreviewMode(isCurrentlyPreview bool) {
|
||||
const url = new URL(window.location.href);
|
||||
if (isCurrentlyPreview) {
|
||||
// Currently previewing, remove preview param
|
||||
url.searchParams.delete('preview');
|
||||
} else {
|
||||
// Not previewing, add preview param
|
||||
url.searchParams.set('preview', '1');
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,50 +0,0 @@
|
||||
package bn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
|
||||
"github.com/a-h/templ"
|
||||
)
|
||||
|
||||
// validationContextKey is the key used to store validation tracking in context.
|
||||
type validationContextKey struct{}
|
||||
|
||||
// ValidationTracker tracks which bn functions are called during template rendering.
|
||||
// Used during template registration to validate templates include required calls.
|
||||
type ValidationTracker struct {
|
||||
HeadCalled bool
|
||||
BodyEndCalled bool
|
||||
}
|
||||
|
||||
// NewValidationContext creates a context with validation tracking enabled.
|
||||
// Use GetValidationTracker after rendering to check which functions were called.
|
||||
func NewValidationContext(ctx context.Context) (context.Context, *ValidationTracker) {
|
||||
tracker := &ValidationTracker{}
|
||||
return context.WithValue(ctx, validationContextKey{}, tracker), tracker
|
||||
}
|
||||
|
||||
// GetValidationTracker retrieves the validation tracker from context, if any.
|
||||
func GetValidationTracker(ctx context.Context) *ValidationTracker {
|
||||
if tracker, ok := ctx.Value(validationContextKey{}).(*ValidationTracker); ok {
|
||||
return tracker
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordValidationCall records that a bn function was called during rendering.
|
||||
// Returns an empty component (renders nothing) but has the side effect of tracking the call.
|
||||
func recordValidationCall(ctx context.Context, fnName string) templ.Component {
|
||||
if tracker := GetValidationTracker(ctx); tracker != nil {
|
||||
switch fnName {
|
||||
case "Head":
|
||||
tracker.HeadCalled = true
|
||||
case "BodyEnd":
|
||||
tracker.BodyEndCalled = true
|
||||
}
|
||||
}
|
||||
// Return empty component - renders nothing
|
||||
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
|
||||
return nil
|
||||
})
|
||||
}
|
||||
27
templates/pagedoc.go
Normal file
27
templates/pagedoc.go
Normal file
@ -0,0 +1,27 @@
|
||||
package templates
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
)
|
||||
|
||||
// PageDocument is a full-page template's contribution to the document. The
|
||||
// HOST owns the envelope (doctype/<html>/<head>/<body> and the bn chrome);
|
||||
// the guest supplies the body interior and envelope attributes. PageDocument
|
||||
// is itself an HTMLComponent (renders Body), so TemplateFunc's signature is
|
||||
// unchanged and non-page callers need no adaptation.
|
||||
type PageDocument struct {
|
||||
Body HTMLComponent
|
||||
BodyClass string
|
||||
Lang string // <html lang>; empty means host default "en"
|
||||
HTMLAttrs map[string]string // extra <html> attributes (e.g. data-theme)
|
||||
HeadExtra HTMLComponent // appended inside <head> after bn.Head
|
||||
BodyEndExtra HTMLComponent // appended before </body> after bn.BodyEnd
|
||||
}
|
||||
|
||||
func (p *PageDocument) Render(ctx context.Context, w io.Writer) error {
|
||||
if p.Body == nil {
|
||||
return nil
|
||||
}
|
||||
return p.Body.Render(ctx, w)
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="{{ theme_mode }}">
|
||||
{{ head_html|safe }}
|
||||
<body class="{% block body_class %}bg-background text-foreground antialiased min-h-screen flex flex-col{% endblock %}">
|
||||
{{ admin_banner_html|safe }}
|
||||
{% block body %}{% endblock %}
|
||||
{{ body_end_html|safe }}
|
||||
</body>
|
||||
</html>
|
||||
@ -1,100 +0,0 @@
|
||||
package pongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
|
||||
"git.dev.alexdunmow.com/block/ninjatpl"
|
||||
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/templates/bn"
|
||||
)
|
||||
|
||||
// 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) ninjatpl.Context {
|
||||
title := "Untitled"
|
||||
if t, ok := doc["title"].(string); ok && t != "" {
|
||||
title = t
|
||||
}
|
||||
|
||||
slots := make(map[string]string)
|
||||
if s, ok := doc["slots"].(map[string]string); ok {
|
||||
slots = s
|
||||
}
|
||||
|
||||
themeMode := "dark"
|
||||
if tm, ok := doc["theme_mode"].(string); ok && tm != "" {
|
||||
themeMode = tm
|
||||
}
|
||||
|
||||
themeCSS := ""
|
||||
if tc, ok := doc["theme_css"].(string); ok {
|
||||
themeCSS = tc
|
||||
}
|
||||
|
||||
structuredData := ""
|
||||
if sd, ok := doc["structured_data"].(string); ok {
|
||||
structuredData = sd
|
||||
}
|
||||
|
||||
cssHash := ""
|
||||
if ch, ok := doc["css_hash"].(string); ok {
|
||||
cssHash = ch
|
||||
}
|
||||
|
||||
pageviewNonce := ""
|
||||
if pn, ok := doc["pageview_nonce"].(string); ok {
|
||||
pageviewNonce = pn
|
||||
}
|
||||
|
||||
settings := bn.ParseSiteSettings(doc)
|
||||
pageMeta := bn.ParsePageMeta(doc)
|
||||
engagementConfig := bn.ParseEngagementConfig(doc)
|
||||
|
||||
headHTML := renderComponent(ctx, bn.Head(bn.HeadData{
|
||||
Title: title,
|
||||
Settings: settings,
|
||||
PageMeta: pageMeta,
|
||||
ThemeMode: themeMode,
|
||||
ThemeCSS: themeCSS,
|
||||
PluginStyles: e.stylePaths,
|
||||
StructuredData: structuredData,
|
||||
CSSHash: cssHash,
|
||||
PageviewNonce: pageviewNonce,
|
||||
EngagementConfig: engagementConfig,
|
||||
}))
|
||||
bodyEndHTML := renderComponent(ctx, bn.BodyEnd(settings))
|
||||
bannerHTML := renderComponent(ctx, bn.AdminBypassBanner(settings))
|
||||
|
||||
slotsAny := make(map[string]any, len(slots))
|
||||
for k, v := range slots {
|
||||
slotsAny[k] = v
|
||||
}
|
||||
|
||||
return ninjatpl.Context{
|
||||
"head_html": headHTML,
|
||||
"body_end_html": bodyEndHTML,
|
||||
"admin_banner_html": bannerHTML,
|
||||
|
||||
"title": title,
|
||||
"slots": slotsAny,
|
||||
"theme_mode": themeMode,
|
||||
"theme_css": themeCSS,
|
||||
"css_hash": cssHash,
|
||||
|
||||
"site_settings": settings,
|
||||
"page_meta": pageMeta,
|
||||
}
|
||||
}
|
||||
|
||||
// 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) ninjatpl.Context {
|
||||
pongoCtx := make(ninjatpl.Context, len(content)+1)
|
||||
maps.Copy(pongoCtx, content)
|
||||
if bc := blocks.GetBlockContext(ctx); bc != nil {
|
||||
pongoCtx["ctx"] = bc.ToMap()
|
||||
}
|
||||
return pongoCtx
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
package pongo
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed base.html
|
||||
var baseFS embed.FS
|
||||
@ -1,169 +0,0 @@
|
||||
package pongo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"io/fs"
|
||||
"maps"
|
||||
|
||||
"git.dev.alexdunmow.com/block/ninjatpl"
|
||||
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
||||
"git.dev.alexdunmow.com/block/pluginsdk/templates"
|
||||
)
|
||||
|
||||
// Engine provides first-class pongo2 template support for BlockNinja plugins.
|
||||
// Plugins create an Engine with their embedded template FS, then call
|
||||
// MustPageTemplate / MustBlockTemplate to get functions compatible with
|
||||
// the template and block registries.
|
||||
type Engine struct {
|
||||
set *ninjatpl.TemplateSet
|
||||
stylePaths []string
|
||||
}
|
||||
|
||||
// NewEngine creates a pongo2 Engine.
|
||||
// pluginFS should contain the plugin's .html templates.
|
||||
// stylePaths are CSS URLs included in the page <head> via bn.Head.
|
||||
func NewEngine(pluginFS fs.FS, stylePaths ...string) *Engine {
|
||||
loader := &multiLoader{
|
||||
loaders: []ninjatpl.TemplateLoader{
|
||||
&fsLoader{fsys: pluginFS},
|
||||
&fsLoader{fsys: baseFS},
|
||||
},
|
||||
}
|
||||
set := ninjatpl.NewSet("plugin", loader)
|
||||
return &Engine{set: set, stylePaths: stylePaths}
|
||||
}
|
||||
|
||||
// pongoComponent wraps a pongo2 template execution as an HTMLComponent.
|
||||
type pongoComponent struct {
|
||||
tpl *ninjatpl.Template
|
||||
ctx ninjatpl.Context
|
||||
}
|
||||
|
||||
func (c *pongoComponent) Render(ctx context.Context, w io.Writer) error {
|
||||
return c.tpl.ExecuteWriter(c.ctx, w)
|
||||
}
|
||||
|
||||
// MustPageTemplate parses a pongo2 page template and returns a TemplateFunc.
|
||||
// Panics on parse error.
|
||||
//
|
||||
// Available context variables in page templates:
|
||||
//
|
||||
// {{ head_html|safe }} — full <head> element
|
||||
// {{ body_end_html|safe }} — body-end scripts and admin toolbar
|
||||
// {{ admin_banner_html|safe }} — maintenance/coming-soon admin banner
|
||||
// {{ title }} — page title
|
||||
// {{ slots.header|safe }} — rendered slot HTML
|
||||
// {{ theme_mode }} — "light", "dark", or "system"
|
||||
// {{ theme_css }} — raw CSS custom properties
|
||||
// {{ css_hash }} — cache-busting hash
|
||||
// {{ site_settings }} — bn.SiteSettingsData struct
|
||||
// {{ page_meta }} — bn.PageMeta struct
|
||||
func (e *Engine) MustPageTemplate(name string) templates.TemplateFunc {
|
||||
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}
|
||||
}
|
||||
}
|
||||
|
||||
// MustBlockTemplate parses a pongo2 block template and returns a BlockFunc.
|
||||
// Panics on parse error.
|
||||
//
|
||||
// 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 := 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)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return blocks.ProcessIcons(out)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := 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)
|
||||
maps.Copy(merged, content)
|
||||
pongoCtx := buildBlockContext(ctx, merged)
|
||||
out, err := tpl.Execute(pongoCtx)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return blocks.ProcessIcons(out)
|
||||
}
|
||||
}
|
||||
|
||||
// MustTemplateOverride parses a pongo2 template for use as a block template
|
||||
// override (e.g., custom heading/text styling). Same as MustBlockTemplate
|
||||
// but named distinctly for clarity at the call site.
|
||||
func (e *Engine) MustTemplateOverride(name string) blocks.BlockFunc {
|
||||
return e.MustBlockTemplate(name)
|
||||
}
|
||||
|
||||
// MustEmailWrapper parses a pongo2 email template and returns an
|
||||
// EmailWrapperFunc. Panics on parse error.
|
||||
//
|
||||
// Available context variables:
|
||||
//
|
||||
// {{ body|safe }} — the email body HTML
|
||||
// {{ site_name }} — site name
|
||||
// {{ site_url }} — site URL
|
||||
// {{ logo_url }} — site logo URL
|
||||
// {{ unsubscribe_url }} — unsubscribe link (may be empty)
|
||||
// {{ preview_text }} — email preview/preheader text
|
||||
// {{ colors.primary }} — primary hex color
|
||||
// {{ colors.secondary }} — secondary hex color
|
||||
// {{ colors.background }} — background hex color
|
||||
// {{ colors.foreground }} — foreground hex color
|
||||
// {{ colors.border }} — border hex color
|
||||
// {{ colors.muted }} — muted hex color
|
||||
// (and all other EmailColors fields as lowercase keys)
|
||||
func (e *Engine) MustEmailWrapper(name string) templates.EmailWrapperFunc {
|
||||
tpl := ninjatpl.Must(e.set.FromFile(name))
|
||||
return func(body string, ctx templates.EmailContext) string {
|
||||
pongoCtx := ninjatpl.Context{
|
||||
"body": body,
|
||||
"site_name": ctx.SiteSettings.SiteName,
|
||||
"site_url": ctx.SiteSettings.SiteURL,
|
||||
"logo_url": ctx.SiteSettings.LogoURL,
|
||||
"support_email": ctx.SiteSettings.SupportEmail,
|
||||
"unsubscribe_url": ctx.UnsubscribeURL,
|
||||
"preview_text": ctx.PreviewText,
|
||||
"colors": map[string]any{
|
||||
"primary": ctx.Colors.Primary,
|
||||
"primaryForeground": ctx.Colors.PrimaryForeground,
|
||||
"secondary": ctx.Colors.Secondary,
|
||||
"secondaryForeground": ctx.Colors.SecondaryForeground,
|
||||
"background": ctx.Colors.Background,
|
||||
"foreground": ctx.Colors.Foreground,
|
||||
"muted": ctx.Colors.Muted,
|
||||
"mutedForeground": ctx.Colors.MutedForeground,
|
||||
"border": ctx.Colors.Border,
|
||||
"card": ctx.Colors.Card,
|
||||
"cardForeground": ctx.Colors.CardForeground,
|
||||
},
|
||||
}
|
||||
out, err := tpl.Execute(pongoCtx)
|
||||
if err != nil {
|
||||
return body
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
// renderComponent renders any HTMLComponent to a string.
|
||||
func renderComponent(ctx context.Context, c templates.HTMLComponent) string {
|
||||
var buf bytes.Buffer
|
||||
_ = c.Render(ctx, &buf)
|
||||
return buf.String()
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
package pongo
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
|
||||
"git.dev.alexdunmow.com/block/ninjatpl"
|
||||
)
|
||||
|
||||
// fsLoader adapts an fs.FS to pongo2's TemplateLoader interface.
|
||||
type fsLoader struct {
|
||||
fsys fs.FS
|
||||
}
|
||||
|
||||
func (l *fsLoader) Abs(base, name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func (l *fsLoader) Get(path string) (io.Reader, error) {
|
||||
return l.fsys.Open(path)
|
||||
}
|
||||
|
||||
// multiLoader chains multiple loaders, returning the first hit.
|
||||
// Plugin templates can {% extends "base.html" %} where base.html
|
||||
// lives in the core embedded FS rather than the plugin FS.
|
||||
type multiLoader struct {
|
||||
loaders []ninjatpl.TemplateLoader
|
||||
}
|
||||
|
||||
func (l *multiLoader) Abs(base, name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
func (l *multiLoader) Get(path string) (io.Reader, error) {
|
||||
for _, loader := range l.loaders {
|
||||
r, err := loader.Get(path)
|
||||
if err == nil {
|
||||
return r, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("pongo: template %q not found in any loader", path)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user