Compare commits
No commits in common. "main" and "v0.2.0" have entirely different histories.
@ -17,12 +17,9 @@ between first-party entities (cms, orchestrator, ninja CLI).
|
|||||||
the ABI hooks directly and never sees these Go structs.
|
the ABI hooks directly and never sees these Go structs.
|
||||||
- **The SDK ships no page chrome.** A template renders only its
|
- **The SDK ships no page chrome.** A template renders only its
|
||||||
`TemplateDocument`; the **host** composes the document envelope
|
`TemplateDocument`; the **host** composes the document envelope
|
||||||
(doctype / `<html>` / `<head>` / `<body>` + bn chrome) around it. Through
|
(doctype / `<html>` / `<head>` / `<body>` + bn chrome) around it. A guest
|
||||||
`templates.PageDocument` a guest supplies the body plus envelope attributes
|
expresses its envelope needs (title, meta, asset hooks) through
|
||||||
(body class, `lang`, extra `<html>` attributes, and head/body-end extra
|
`templates.PageDocument` — never by emitting head/body markup itself.
|
||||||
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
|
## Critical Rules
|
||||||
|
|
||||||
|
|||||||
14
README.md
14
README.md
@ -16,15 +16,23 @@ never the first-party `core` module.
|
|||||||
context rehydration), its capability stubs (`plugin/wasmguest/caps/`), and the
|
context rehydration), its capability stubs (`plugin/wasmguest/caps/`), and the
|
||||||
`database/sql` driver over the host DB (`plugin/wasmguest/bnwasm/`).
|
`database/sql` driver over the host DB (`plugin/wasmguest/bnwasm/`).
|
||||||
- **Guest-facing type packages** plugins consume: `blocks/` (incl. `blocks/builtin`,
|
- **Guest-facing type packages** plugins consume: `blocks/` (incl. `blocks/builtin`,
|
||||||
`blocks/shared`, `blocks/tags`), `templates/` (the template registry + types,
|
`blocks/shared`, `blocks/tags`), `templates/` (registry + `templates/pongo`),
|
||||||
including `PageDocument` — the body-plus-envelope contribution a full-page
|
`templates/bn/` (the templ chrome — see sync rule below), `auth/`, `settings/`,
|
||||||
template makes; the host owns the chrome), `auth/`, `settings/`,
|
|
||||||
`content/`, `gating/`, `crypto/`, `rbac/`, `video/`, `ai/`, `subscriptions/`,
|
`content/`, `gating/`, `crypto/`, `rbac/`, `video/`, `ai/`, `subscriptions/`,
|
||||||
`menus/`, `datasources/`.
|
`menus/`, `datasources/`.
|
||||||
|
|
||||||
The Go surface here is **one language binding**. The proto tree is the contract:
|
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.
|
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
|
## Rules
|
||||||
|
|
||||||
- **NEVER use `replace` directives** in `go.mod`. Module resolution goes through the
|
- **NEVER use `replace` directives** in `go.mod`. Module resolution goes through the
|
||||||
|
|||||||
@ -25,7 +25,6 @@ syntax = "proto3";
|
|||||||
package abi.v1;
|
package abi.v1;
|
||||||
|
|
||||||
import "google/protobuf/timestamp.proto";
|
import "google/protobuf/timestamp.proto";
|
||||||
import "v1/http.proto";
|
|
||||||
import "v1/invoke.proto";
|
import "v1/invoke.proto";
|
||||||
|
|
||||||
option go_package = "git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1";
|
option go_package = "git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1";
|
||||||
@ -83,18 +82,6 @@ message PageInfo {
|
|||||||
string title = 3;
|
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 {
|
message ContentGetPostRequest {
|
||||||
string slug = 1;
|
string slug = 1;
|
||||||
}
|
}
|
||||||
@ -311,79 +298,6 @@ message DatasourceResult {
|
|||||||
bytes meta_json = 3;
|
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) ---
|
// --- users.* (auth.PublicUsers) ---
|
||||||
|
|
||||||
message UsersGetByUsernameRequest {
|
message UsersGetByUsernameRequest {
|
||||||
@ -928,34 +842,3 @@ message BridgeInvokeRequest {
|
|||||||
message BridgeInvokeResponse {
|
message BridgeInvokeResponse {
|
||||||
bytes payload = 1;
|
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,12 +101,6 @@ enum AbiErrorCode {
|
|||||||
// the cms dbexec side (adopted separately) and mapped guest-side to
|
// the cms dbexec side (adopted separately) and mapped guest-side to
|
||||||
// bnwasm.ErrTxExpired.
|
// bnwasm.ErrTxExpired.
|
||||||
ABI_ERROR_CODE_TX_EXPIRED = 6;
|
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
|
// AbiError is the structured error carried by InvokeResponse and
|
||||||
|
|||||||
@ -125,48 +125,6 @@ message PluginManifest {
|
|||||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||||
// plugin.mod + the artifact's declarative files.
|
// plugin.mod + the artifact's declarative files.
|
||||||
bool codeless = 33;
|
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.
|
// Dependency mirrors plugin.Dependency.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -168,12 +168,6 @@ const (
|
|||||||
// the cms dbexec side (adopted separately) and mapped guest-side to
|
// the cms dbexec side (adopted separately) and mapped guest-side to
|
||||||
// bnwasm.ErrTxExpired.
|
// bnwasm.ErrTxExpired.
|
||||||
AbiErrorCode_ABI_ERROR_CODE_TX_EXPIRED AbiErrorCode = 6
|
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.
|
// Enum value maps for AbiErrorCode.
|
||||||
@ -186,7 +180,6 @@ var (
|
|||||||
4: "ABI_ERROR_CODE_DEADLINE_EXCEEDED",
|
4: "ABI_ERROR_CODE_DEADLINE_EXCEEDED",
|
||||||
5: "ABI_ERROR_CODE_PERMISSION_DENIED",
|
5: "ABI_ERROR_CODE_PERMISSION_DENIED",
|
||||||
6: "ABI_ERROR_CODE_TX_EXPIRED",
|
6: "ABI_ERROR_CODE_TX_EXPIRED",
|
||||||
7: "ABI_ERROR_CODE_EGRESS_DENIED",
|
|
||||||
}
|
}
|
||||||
AbiErrorCode_value = map[string]int32{
|
AbiErrorCode_value = map[string]int32{
|
||||||
"ABI_ERROR_CODE_UNSPECIFIED": 0,
|
"ABI_ERROR_CODE_UNSPECIFIED": 0,
|
||||||
@ -196,7 +189,6 @@ var (
|
|||||||
"ABI_ERROR_CODE_DEADLINE_EXCEEDED": 4,
|
"ABI_ERROR_CODE_DEADLINE_EXCEEDED": 4,
|
||||||
"ABI_ERROR_CODE_PERMISSION_DENIED": 5,
|
"ABI_ERROR_CODE_PERMISSION_DENIED": 5,
|
||||||
"ABI_ERROR_CODE_TX_EXPIRED": 6,
|
"ABI_ERROR_CODE_TX_EXPIRED": 6,
|
||||||
"ABI_ERROR_CODE_EGRESS_DENIED": 7,
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -1798,7 +1790,7 @@ const file_v1_invoke_proto_rawDesc = "" +
|
|||||||
"\x11HOOK_AI_TOOL_CALL\x10\f\x12\x14\n" +
|
"\x11HOOK_AI_TOOL_CALL\x10\f\x12\x14\n" +
|
||||||
"\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" +
|
"\x10HOOK_BRIDGE_CALL\x10\r\x12 \n" +
|
||||||
"\x1cHOOK_DIRECTORY_PANEL_SECTION\x10\x0e\x12 \n" +
|
"\x1cHOOK_DIRECTORY_PANEL_SECTION\x10\x0e\x12 \n" +
|
||||||
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\x95\x02\n" +
|
"\x1cHOOK_DIRECTORY_PIN_DECORATOR\x10\x0f*\xf3\x01\n" +
|
||||||
"\fAbiErrorCode\x12\x1e\n" +
|
"\fAbiErrorCode\x12\x1e\n" +
|
||||||
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
|
"\x1aABI_ERROR_CODE_UNSPECIFIED\x10\x00\x12\x1b\n" +
|
||||||
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" +
|
"\x17ABI_ERROR_CODE_INTERNAL\x10\x01\x12\x19\n" +
|
||||||
@ -1806,8 +1798,7 @@ const file_v1_invoke_proto_rawDesc = "" +
|
|||||||
"\x1cABI_ERROR_CODE_UNIMPLEMENTED\x10\x03\x12$\n" +
|
"\x1cABI_ERROR_CODE_UNIMPLEMENTED\x10\x03\x12$\n" +
|
||||||
" ABI_ERROR_CODE_DEADLINE_EXCEEDED\x10\x04\x12$\n" +
|
" ABI_ERROR_CODE_DEADLINE_EXCEEDED\x10\x04\x12$\n" +
|
||||||
" ABI_ERROR_CODE_PERMISSION_DENIED\x10\x05\x12\x1d\n" +
|
" ABI_ERROR_CODE_PERMISSION_DENIED\x10\x05\x12\x1d\n" +
|
||||||
"\x19ABI_ERROR_CODE_TX_EXPIRED\x10\x06\x12 \n" +
|
"\x19ABI_ERROR_CODE_TX_EXPIRED\x10\x06B5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3"
|
||||||
"\x1cABI_ERROR_CODE_EGRESS_DENIED\x10\aB5Z3git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1b\x06proto3"
|
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_v1_invoke_proto_rawDescOnce sync.Once
|
file_v1_invoke_proto_rawDescOnce sync.Once
|
||||||
|
|||||||
@ -123,33 +123,6 @@ type PluginManifest struct {
|
|||||||
// to describe): `ninja plugin build` synthesizes the manifest from
|
// to describe): `ninja plugin build` synthesizes the manifest from
|
||||||
// plugin.mod + the artifact's declarative files.
|
// 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
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@ -415,92 +388,6 @@ func (x *PluginManifest) GetCodeless() bool {
|
|||||||
return false
|
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.
|
// Dependency mirrors plugin.Dependency.
|
||||||
type Dependency struct {
|
type Dependency struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@ -513,7 +400,7 @@ type Dependency struct {
|
|||||||
|
|
||||||
func (x *Dependency) Reset() {
|
func (x *Dependency) Reset() {
|
||||||
*x = Dependency{}
|
*x = Dependency{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -525,7 +412,7 @@ func (x *Dependency) String() string {
|
|||||||
func (*Dependency) ProtoMessage() {}
|
func (*Dependency) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Dependency) ProtoReflect() protoreflect.Message {
|
func (x *Dependency) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[2]
|
mi := &file_v1_manifest_proto_msgTypes[1]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -538,7 +425,7 @@ func (x *Dependency) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Dependency.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Dependency.ProtoReflect.Descriptor instead.
|
||||||
func (*Dependency) Descriptor() ([]byte, []int) {
|
func (*Dependency) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{2}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{1}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Dependency) GetPlugin() string {
|
func (x *Dependency) GetPlugin() string {
|
||||||
@ -581,7 +468,7 @@ type BlockMeta struct {
|
|||||||
|
|
||||||
func (x *BlockMeta) Reset() {
|
func (x *BlockMeta) Reset() {
|
||||||
*x = BlockMeta{}
|
*x = BlockMeta{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -593,7 +480,7 @@ func (x *BlockMeta) String() string {
|
|||||||
func (*BlockMeta) ProtoMessage() {}
|
func (*BlockMeta) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *BlockMeta) ProtoReflect() protoreflect.Message {
|
func (x *BlockMeta) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[3]
|
mi := &file_v1_manifest_proto_msgTypes[2]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -606,7 +493,7 @@ func (x *BlockMeta) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead.
|
// Deprecated: Use BlockMeta.ProtoReflect.Descriptor instead.
|
||||||
func (*BlockMeta) Descriptor() ([]byte, []int) {
|
func (*BlockMeta) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{3}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{2}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *BlockMeta) GetKey() string {
|
func (x *BlockMeta) GetKey() string {
|
||||||
@ -672,7 +559,7 @@ type BlockTemplateOverride struct {
|
|||||||
|
|
||||||
func (x *BlockTemplateOverride) Reset() {
|
func (x *BlockTemplateOverride) Reset() {
|
||||||
*x = BlockTemplateOverride{}
|
*x = BlockTemplateOverride{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -684,7 +571,7 @@ func (x *BlockTemplateOverride) String() string {
|
|||||||
func (*BlockTemplateOverride) ProtoMessage() {}
|
func (*BlockTemplateOverride) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
|
func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[4]
|
mi := &file_v1_manifest_proto_msgTypes[3]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -697,7 +584,7 @@ func (x *BlockTemplateOverride) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead.
|
// Deprecated: Use BlockTemplateOverride.ProtoReflect.Descriptor instead.
|
||||||
func (*BlockTemplateOverride) Descriptor() ([]byte, []int) {
|
func (*BlockTemplateOverride) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{4}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{3}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *BlockTemplateOverride) GetTemplateKey() string {
|
func (x *BlockTemplateOverride) GetTemplateKey() string {
|
||||||
@ -733,7 +620,7 @@ type SystemTemplateMeta struct {
|
|||||||
|
|
||||||
func (x *SystemTemplateMeta) Reset() {
|
func (x *SystemTemplateMeta) Reset() {
|
||||||
*x = SystemTemplateMeta{}
|
*x = SystemTemplateMeta{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -745,7 +632,7 @@ func (x *SystemTemplateMeta) String() string {
|
|||||||
func (*SystemTemplateMeta) ProtoMessage() {}
|
func (*SystemTemplateMeta) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
|
func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[5]
|
mi := &file_v1_manifest_proto_msgTypes[4]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -758,7 +645,7 @@ func (x *SystemTemplateMeta) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead.
|
// Deprecated: Use SystemTemplateMeta.ProtoReflect.Descriptor instead.
|
||||||
func (*SystemTemplateMeta) Descriptor() ([]byte, []int) {
|
func (*SystemTemplateMeta) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{5}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *SystemTemplateMeta) GetKey() string {
|
func (x *SystemTemplateMeta) GetKey() string {
|
||||||
@ -797,7 +684,7 @@ type PageTemplateMeta struct {
|
|||||||
|
|
||||||
func (x *PageTemplateMeta) Reset() {
|
func (x *PageTemplateMeta) Reset() {
|
||||||
*x = PageTemplateMeta{}
|
*x = PageTemplateMeta{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -809,7 +696,7 @@ func (x *PageTemplateMeta) String() string {
|
|||||||
func (*PageTemplateMeta) ProtoMessage() {}
|
func (*PageTemplateMeta) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
|
func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[6]
|
mi := &file_v1_manifest_proto_msgTypes[5]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -822,7 +709,7 @@ func (x *PageTemplateMeta) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead.
|
// Deprecated: Use PageTemplateMeta.ProtoReflect.Descriptor instead.
|
||||||
func (*PageTemplateMeta) Descriptor() ([]byte, []int) {
|
func (*PageTemplateMeta) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{6}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{5}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *PageTemplateMeta) GetSystemKey() string {
|
func (x *PageTemplateMeta) GetSystemKey() string {
|
||||||
@ -873,7 +760,7 @@ type AdminPage struct {
|
|||||||
|
|
||||||
func (x *AdminPage) Reset() {
|
func (x *AdminPage) Reset() {
|
||||||
*x = AdminPage{}
|
*x = AdminPage{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -885,7 +772,7 @@ func (x *AdminPage) String() string {
|
|||||||
func (*AdminPage) ProtoMessage() {}
|
func (*AdminPage) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *AdminPage) ProtoReflect() protoreflect.Message {
|
func (x *AdminPage) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[7]
|
mi := &file_v1_manifest_proto_msgTypes[6]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -898,7 +785,7 @@ func (x *AdminPage) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use AdminPage.ProtoReflect.Descriptor instead.
|
// Deprecated: Use AdminPage.ProtoReflect.Descriptor instead.
|
||||||
func (*AdminPage) Descriptor() ([]byte, []int) {
|
func (*AdminPage) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{7}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{6}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AdminPage) GetKey() string {
|
func (x *AdminPage) GetKey() string {
|
||||||
@ -943,7 +830,7 @@ type AiAction struct {
|
|||||||
|
|
||||||
func (x *AiAction) Reset() {
|
func (x *AiAction) Reset() {
|
||||||
*x = AiAction{}
|
*x = AiAction{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -955,7 +842,7 @@ func (x *AiAction) String() string {
|
|||||||
func (*AiAction) ProtoMessage() {}
|
func (*AiAction) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *AiAction) ProtoReflect() protoreflect.Message {
|
func (x *AiAction) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[8]
|
mi := &file_v1_manifest_proto_msgTypes[7]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -968,7 +855,7 @@ func (x *AiAction) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use AiAction.ProtoReflect.Descriptor instead.
|
// Deprecated: Use AiAction.ProtoReflect.Descriptor instead.
|
||||||
func (*AiAction) Descriptor() ([]byte, []int) {
|
func (*AiAction) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{8}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{7}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *AiAction) GetKey() string {
|
func (x *AiAction) GetKey() string {
|
||||||
@ -1018,7 +905,7 @@ type CssManifest struct {
|
|||||||
|
|
||||||
func (x *CssManifest) Reset() {
|
func (x *CssManifest) Reset() {
|
||||||
*x = CssManifest{}
|
*x = CssManifest{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -1030,7 +917,7 @@ func (x *CssManifest) String() string {
|
|||||||
func (*CssManifest) ProtoMessage() {}
|
func (*CssManifest) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *CssManifest) ProtoReflect() protoreflect.Message {
|
func (x *CssManifest) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[9]
|
mi := &file_v1_manifest_proto_msgTypes[8]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -1043,7 +930,7 @@ func (x *CssManifest) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use CssManifest.ProtoReflect.Descriptor instead.
|
// Deprecated: Use CssManifest.ProtoReflect.Descriptor instead.
|
||||||
func (*CssManifest) Descriptor() ([]byte, []int) {
|
func (*CssManifest) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{9}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{8}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CssManifest) GetNpmPackages() map[string]string {
|
func (x *CssManifest) GetNpmPackages() map[string]string {
|
||||||
@ -1084,7 +971,7 @@ type DirectoryExtensions struct {
|
|||||||
|
|
||||||
func (x *DirectoryExtensions) Reset() {
|
func (x *DirectoryExtensions) Reset() {
|
||||||
*x = DirectoryExtensions{}
|
*x = DirectoryExtensions{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -1096,7 +983,7 @@ func (x *DirectoryExtensions) String() string {
|
|||||||
func (*DirectoryExtensions) ProtoMessage() {}
|
func (*DirectoryExtensions) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
|
func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[10]
|
mi := &file_v1_manifest_proto_msgTypes[9]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -1109,7 +996,7 @@ func (x *DirectoryExtensions) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead.
|
// Deprecated: Use DirectoryExtensions.ProtoReflect.Descriptor instead.
|
||||||
func (*DirectoryExtensions) Descriptor() ([]byte, []int) {
|
func (*DirectoryExtensions) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{10}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{9}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *DirectoryExtensions) GetBooleanFilterFields() []string {
|
func (x *DirectoryExtensions) GetBooleanFilterFields() []string {
|
||||||
@ -1158,7 +1045,7 @@ type BadgeLabel struct {
|
|||||||
|
|
||||||
func (x *BadgeLabel) Reset() {
|
func (x *BadgeLabel) Reset() {
|
||||||
*x = BadgeLabel{}
|
*x = BadgeLabel{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -1170,7 +1057,7 @@ func (x *BadgeLabel) String() string {
|
|||||||
func (*BadgeLabel) ProtoMessage() {}
|
func (*BadgeLabel) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
|
func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[11]
|
mi := &file_v1_manifest_proto_msgTypes[10]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -1183,7 +1070,7 @@ func (x *BadgeLabel) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead.
|
// Deprecated: Use BadgeLabel.ProtoReflect.Descriptor instead.
|
||||||
func (*BadgeLabel) Descriptor() ([]byte, []int) {
|
func (*BadgeLabel) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{11}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{10}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *BadgeLabel) GetPositive() string {
|
func (x *BadgeLabel) GetPositive() string {
|
||||||
@ -1213,7 +1100,7 @@ type MasterPageDefinition struct {
|
|||||||
|
|
||||||
func (x *MasterPageDefinition) Reset() {
|
func (x *MasterPageDefinition) Reset() {
|
||||||
*x = MasterPageDefinition{}
|
*x = MasterPageDefinition{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -1225,7 +1112,7 @@ func (x *MasterPageDefinition) String() string {
|
|||||||
func (*MasterPageDefinition) ProtoMessage() {}
|
func (*MasterPageDefinition) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
|
func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[12]
|
mi := &file_v1_manifest_proto_msgTypes[11]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -1238,7 +1125,7 @@ func (x *MasterPageDefinition) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead.
|
// Deprecated: Use MasterPageDefinition.ProtoReflect.Descriptor instead.
|
||||||
func (*MasterPageDefinition) Descriptor() ([]byte, []int) {
|
func (*MasterPageDefinition) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{12}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{11}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *MasterPageDefinition) GetKey() string {
|
func (x *MasterPageDefinition) GetKey() string {
|
||||||
@ -1285,7 +1172,7 @@ type MasterPageBlock struct {
|
|||||||
|
|
||||||
func (x *MasterPageBlock) Reset() {
|
func (x *MasterPageBlock) Reset() {
|
||||||
*x = MasterPageBlock{}
|
*x = MasterPageBlock{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -1297,7 +1184,7 @@ func (x *MasterPageBlock) String() string {
|
|||||||
func (*MasterPageBlock) ProtoMessage() {}
|
func (*MasterPageBlock) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
|
func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[13]
|
mi := &file_v1_manifest_proto_msgTypes[12]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -1310,7 +1197,7 @@ func (x *MasterPageBlock) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead.
|
// Deprecated: Use MasterPageBlock.ProtoReflect.Descriptor instead.
|
||||||
func (*MasterPageBlock) Descriptor() ([]byte, []int) {
|
func (*MasterPageBlock) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{13}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{12}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *MasterPageBlock) GetBlockKey() string {
|
func (x *MasterPageBlock) GetBlockKey() string {
|
||||||
@ -1367,7 +1254,7 @@ type CoreServiceBinding struct {
|
|||||||
|
|
||||||
func (x *CoreServiceBinding) Reset() {
|
func (x *CoreServiceBinding) Reset() {
|
||||||
*x = CoreServiceBinding{}
|
*x = CoreServiceBinding{}
|
||||||
mi := &file_v1_manifest_proto_msgTypes[14]
|
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
@ -1379,7 +1266,7 @@ func (x *CoreServiceBinding) String() string {
|
|||||||
func (*CoreServiceBinding) ProtoMessage() {}
|
func (*CoreServiceBinding) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
|
func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_v1_manifest_proto_msgTypes[14]
|
mi := &file_v1_manifest_proto_msgTypes[13]
|
||||||
if x != nil {
|
if x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
@ -1392,7 +1279,7 @@ func (x *CoreServiceBinding) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead.
|
// Deprecated: Use CoreServiceBinding.ProtoReflect.Descriptor instead.
|
||||||
func (*CoreServiceBinding) Descriptor() ([]byte, []int) {
|
func (*CoreServiceBinding) Descriptor() ([]byte, []int) {
|
||||||
return file_v1_manifest_proto_rawDescGZIP(), []int{14}
|
return file_v1_manifest_proto_rawDescGZIP(), []int{13}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *CoreServiceBinding) GetServiceName() string {
|
func (x *CoreServiceBinding) GetServiceName() string {
|
||||||
@ -1413,7 +1300,7 @@ var File_v1_manifest_proto protoreflect.FileDescriptor
|
|||||||
|
|
||||||
const file_v1_manifest_proto_rawDesc = "" +
|
const file_v1_manifest_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x11v1/manifest.proto\x12\x06abi.v1\"\xaf\x0e\n" +
|
"\x11v1/manifest.proto\x12\x06abi.v1\"\x8e\r\n" +
|
||||||
"\x0ePluginManifest\x12\x1f\n" +
|
"\x0ePluginManifest\x12\x1f\n" +
|
||||||
"\vabi_version\x18\x01 \x01(\rR\n" +
|
"\vabi_version\x18\x01 \x01(\rR\n" +
|
||||||
"abiVersion\x12\x12\n" +
|
"abiVersion\x12\x12\n" +
|
||||||
@ -1451,17 +1338,10 @@ const file_v1_manifest_proto_rawDesc = "" +
|
|||||||
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
|
"\bdata_dir\x18\x1e \x01(\bR\adataDir\x12#\n" +
|
||||||
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
|
"\rdeclared_tags\x18\x1f \x03(\tR\fdeclaredTags\x12)\n" +
|
||||||
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" +
|
"\x10declared_filters\x18 \x03(\tR\x0fdeclaredFilters\x12\x1a\n" +
|
||||||
"\bcodeless\x18! \x01(\bR\bcodeless\x12#\n" +
|
"\bcodeless\x18! \x01(\bR\bcodeless\x1aB\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" +
|
"\x14RbacMethodRolesEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"9\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"a\n" +
|
||||||
"\vPublicRoute\x12\x12\n" +
|
|
||||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x16\n" +
|
|
||||||
"\x06prefix\x18\x02 \x01(\bR\x06prefix\"a\n" +
|
|
||||||
"\n" +
|
"\n" +
|
||||||
"Dependency\x12\x16\n" +
|
"Dependency\x12\x16\n" +
|
||||||
"\x06plugin\x18\x01 \x01(\tR\x06plugin\x12\x1f\n" +
|
"\x06plugin\x18\x01 \x01(\tR\x06plugin\x12\x1f\n" +
|
||||||
@ -1555,52 +1435,50 @@ func file_v1_manifest_proto_rawDescGZIP() []byte {
|
|||||||
return file_v1_manifest_proto_rawDescData
|
return file_v1_manifest_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 19)
|
var file_v1_manifest_proto_msgTypes = make([]protoimpl.MessageInfo, 18)
|
||||||
var file_v1_manifest_proto_goTypes = []any{
|
var file_v1_manifest_proto_goTypes = []any{
|
||||||
(*PluginManifest)(nil), // 0: abi.v1.PluginManifest
|
(*PluginManifest)(nil), // 0: abi.v1.PluginManifest
|
||||||
(*PublicRoute)(nil), // 1: abi.v1.PublicRoute
|
(*Dependency)(nil), // 1: abi.v1.Dependency
|
||||||
(*Dependency)(nil), // 2: abi.v1.Dependency
|
(*BlockMeta)(nil), // 2: abi.v1.BlockMeta
|
||||||
(*BlockMeta)(nil), // 3: abi.v1.BlockMeta
|
(*BlockTemplateOverride)(nil), // 3: abi.v1.BlockTemplateOverride
|
||||||
(*BlockTemplateOverride)(nil), // 4: abi.v1.BlockTemplateOverride
|
(*SystemTemplateMeta)(nil), // 4: abi.v1.SystemTemplateMeta
|
||||||
(*SystemTemplateMeta)(nil), // 5: abi.v1.SystemTemplateMeta
|
(*PageTemplateMeta)(nil), // 5: abi.v1.PageTemplateMeta
|
||||||
(*PageTemplateMeta)(nil), // 6: abi.v1.PageTemplateMeta
|
(*AdminPage)(nil), // 6: abi.v1.AdminPage
|
||||||
(*AdminPage)(nil), // 7: abi.v1.AdminPage
|
(*AiAction)(nil), // 7: abi.v1.AiAction
|
||||||
(*AiAction)(nil), // 8: abi.v1.AiAction
|
(*CssManifest)(nil), // 8: abi.v1.CssManifest
|
||||||
(*CssManifest)(nil), // 9: abi.v1.CssManifest
|
(*DirectoryExtensions)(nil), // 9: abi.v1.DirectoryExtensions
|
||||||
(*DirectoryExtensions)(nil), // 10: abi.v1.DirectoryExtensions
|
(*BadgeLabel)(nil), // 10: abi.v1.BadgeLabel
|
||||||
(*BadgeLabel)(nil), // 11: abi.v1.BadgeLabel
|
(*MasterPageDefinition)(nil), // 11: abi.v1.MasterPageDefinition
|
||||||
(*MasterPageDefinition)(nil), // 12: abi.v1.MasterPageDefinition
|
(*MasterPageBlock)(nil), // 12: abi.v1.MasterPageBlock
|
||||||
(*MasterPageBlock)(nil), // 13: abi.v1.MasterPageBlock
|
(*CoreServiceBinding)(nil), // 13: abi.v1.CoreServiceBinding
|
||||||
(*CoreServiceBinding)(nil), // 14: abi.v1.CoreServiceBinding
|
nil, // 14: abi.v1.PluginManifest.RbacMethodRolesEntry
|
||||||
nil, // 15: abi.v1.PluginManifest.RbacMethodRolesEntry
|
nil, // 15: abi.v1.CssManifest.NpmPackagesEntry
|
||||||
nil, // 16: abi.v1.CssManifest.NpmPackagesEntry
|
nil, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||||
nil, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
nil, // 17: abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||||
nil, // 18: abi.v1.CoreServiceBinding.MethodRolesEntry
|
|
||||||
}
|
}
|
||||||
var file_v1_manifest_proto_depIdxs = []int32{
|
var file_v1_manifest_proto_depIdxs = []int32{
|
||||||
2, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency
|
1, // 0: abi.v1.PluginManifest.dependencies:type_name -> abi.v1.Dependency
|
||||||
3, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta
|
2, // 1: abi.v1.PluginManifest.blocks:type_name -> abi.v1.BlockMeta
|
||||||
4, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride
|
3, // 2: abi.v1.PluginManifest.block_template_overrides:type_name -> abi.v1.BlockTemplateOverride
|
||||||
5, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta
|
4, // 3: abi.v1.PluginManifest.system_templates:type_name -> abi.v1.SystemTemplateMeta
|
||||||
6, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta
|
5, // 4: abi.v1.PluginManifest.page_templates:type_name -> abi.v1.PageTemplateMeta
|
||||||
7, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage
|
6, // 5: abi.v1.PluginManifest.admin_pages:type_name -> abi.v1.AdminPage
|
||||||
12, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition
|
11, // 6: abi.v1.PluginManifest.master_pages:type_name -> abi.v1.MasterPageDefinition
|
||||||
8, // 7: abi.v1.PluginManifest.ai_actions:type_name -> abi.v1.AiAction
|
7, // 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
|
14, // 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
|
8, // 9: abi.v1.PluginManifest.css_manifest:type_name -> abi.v1.CssManifest
|
||||||
10, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions
|
9, // 10: abi.v1.PluginManifest.directory_extensions:type_name -> abi.v1.DirectoryExtensions
|
||||||
14, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding
|
13, // 11: abi.v1.PluginManifest.core_service_bindings:type_name -> abi.v1.CoreServiceBinding
|
||||||
1, // 12: abi.v1.PluginManifest.public_routes:type_name -> abi.v1.PublicRoute
|
15, // 12: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry
|
||||||
16, // 13: abi.v1.CssManifest.npm_packages:type_name -> abi.v1.CssManifest.NpmPackagesEntry
|
16, // 13: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
||||||
17, // 14: abi.v1.DirectoryExtensions.badge_labels:type_name -> abi.v1.DirectoryExtensions.BadgeLabelsEntry
|
12, // 14: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock
|
||||||
13, // 15: abi.v1.MasterPageDefinition.blocks:type_name -> abi.v1.MasterPageBlock
|
17, // 15: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry
|
||||||
18, // 16: abi.v1.CoreServiceBinding.method_roles:type_name -> abi.v1.CoreServiceBinding.MethodRolesEntry
|
10, // 16: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel
|
||||||
11, // 17: abi.v1.DirectoryExtensions.BadgeLabelsEntry.value:type_name -> abi.v1.BadgeLabel
|
17, // [17:17] is the sub-list for method output_type
|
||||||
18, // [18:18] is the sub-list for method output_type
|
17, // [17:17] is the sub-list for method input_type
|
||||||
18, // [18:18] is the sub-list for method input_type
|
17, // [17:17] is the sub-list for extension type_name
|
||||||
18, // [18:18] is the sub-list for extension type_name
|
17, // [17:17] is the sub-list for extension extendee
|
||||||
18, // [18:18] is the sub-list for extension extendee
|
0, // [0:17] is the sub-list for field type_name
|
||||||
0, // [0:18] is the sub-list for field type_name
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_v1_manifest_proto_init() }
|
func init() { file_v1_manifest_proto_init() }
|
||||||
@ -1608,14 +1486,14 @@ func file_v1_manifest_proto_init() {
|
|||||||
if File_v1_manifest_proto != nil {
|
if File_v1_manifest_proto != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file_v1_manifest_proto_msgTypes[13].OneofWrappers = []any{}
|
file_v1_manifest_proto_msgTypes[12].OneofWrappers = []any{}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_v1_manifest_proto_rawDesc), len(file_v1_manifest_proto_rawDesc)),
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 19,
|
NumMessages: 18,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 0,
|
NumServices: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -36,13 +36,6 @@ const (
|
|||||||
HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id"
|
HeaderVerifiedPublicUserID = "X-Bn-Verified-Public-User-Id"
|
||||||
HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username"
|
HeaderVerifiedPublicUsername = "X-Bn-Verified-Public-Username"
|
||||||
HeaderVerifiedPublicEmail = "X-Bn-Verified-Public-Email"
|
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
|
// AllTrustedHeaders lists every trusted identity header, canonical form. The
|
||||||
@ -56,16 +49,9 @@ func AllTrustedHeaders() []string {
|
|||||||
HeaderVerifiedPublicUserID,
|
HeaderVerifiedPublicUserID,
|
||||||
HeaderVerifiedPublicUsername,
|
HeaderVerifiedPublicUsername,
|
||||||
HeaderVerifiedPublicEmail,
|
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
|
// ContextFromTrustedHeaders rebuilds the request's auth context from the host's
|
||||||
// verified identity headers. Trust model: see the const block above — these
|
// verified identity headers. Trust model: see the const block above — these
|
||||||
// headers are host-controlled, so no token parsing or signature check happens
|
// headers are host-controlled, so no token parsing or signature check happens
|
||||||
|
|||||||
@ -80,27 +80,3 @@ func TestTrustedHeaderMiddleware(t *testing.T) {
|
|||||||
t.Fatalf("middleware did not install principal: %+v", got)
|
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,17 +13,6 @@ type MediaValue struct {
|
|||||||
Filename string // Original filename
|
Filename string // Original filename
|
||||||
Width int // Image width in pixels
|
Width int // Image width in pixels
|
||||||
Height int // Image height 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.
|
// String renders the default <img> tag representation.
|
||||||
|
|||||||
@ -59,12 +59,6 @@ type ListPostsParams struct {
|
|||||||
type Content interface {
|
type Content interface {
|
||||||
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
|
GetAuthorProfile(ctx context.Context, id uuid.UUID) (*AuthorProfile, error)
|
||||||
GetPage(ctx context.Context, slug string) (*PageInfo, 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)
|
GetPost(ctx context.Context, slug string) (*PostInfo, error)
|
||||||
ListPosts(ctx context.Context, params ListPostsParams) ([]PostInfo, error)
|
ListPosts(ctx context.Context, params ListPostsParams) ([]PostInfo, error)
|
||||||
Slugify(text string) string
|
Slugify(text string) string
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
// 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
183
egress/egress.go
@ -1,183 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
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 (
|
require (
|
||||||
connectrpc.com/connect v1.20.0
|
connectrpc.com/connect v1.20.0
|
||||||
|
git.dev.alexdunmow.com/block/ninjatpl v1.0.2
|
||||||
github.com/BurntSushi/toml v1.6.0
|
github.com/BurntSushi/toml v1.6.0
|
||||||
github.com/a-h/templ v0.3.1020
|
github.com/a-h/templ v0.3.1020
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/jackc/pgx/v5 v5.10.0
|
github.com/jackc/pgx/v5 v5.10.0
|
||||||
github.com/tetratelabs/wazero v1.12.0
|
github.com/tetratelabs/wazero v1.12.0
|
||||||
golang.org/x/mod v0.37.0
|
golang.org/x/mod v0.37.0
|
||||||
golang.org/x/net v0.56.0
|
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
golang.org/x/sys v0.46.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.38.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
16
go.sum
16
go.sum
@ -1,5 +1,7 @@
|
|||||||
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
connectrpc.com/connect v1.20.0 h1:6TNDAB+WeNd2uolWNlYczB5E0KNNaVMNUEx8JEUsPmQ=
|
||||||
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
connectrpc.com/connect v1.20.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
|
||||||
|
git.dev.alexdunmow.com/block/ninjatpl v1.0.2 h1:KQS90HNW35PSzvwDZ3Z9OOwX3OSjX0x62w0AtOYps8s=
|
||||||
|
git.dev.alexdunmow.com/block/ninjatpl v1.0.2/go.mod h1:WrLz0KysnP5EzJlRCkU2VC1uvazyAXZR99Tn+3Mx1pw=
|
||||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
github.com/a-h/templ v0.3.1020 h1:ypAT/L5ySWEnZ6Zft/5yfoWXYYkhFNvEFOeeqecg4tw=
|
||||||
@ -30,14 +32,12 @@ 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=
|
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 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||||
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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
|||||||
@ -2,7 +2,6 @@ package plugin
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"connectrpc.com/connect"
|
"connectrpc.com/connect"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
||||||
@ -10,7 +9,6 @@ import (
|
|||||||
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
|
"git.dev.alexdunmow.com/block/pluginsdk/crypto"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/datasources"
|
"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/gating"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/menus"
|
"git.dev.alexdunmow.com/block/pluginsdk/menus"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
"git.dev.alexdunmow.com/block/pluginsdk/settings"
|
||||||
@ -28,7 +26,6 @@ type CoreServices struct {
|
|||||||
Crypto crypto.Crypto
|
Crypto crypto.Crypto
|
||||||
Menus menus.Menus
|
Menus menus.Menus
|
||||||
Datasources datasources.Datasources
|
Datasources datasources.Datasources
|
||||||
DataTables datatables.DataTables
|
|
||||||
PublicUsers auth.PublicUsers
|
PublicUsers auth.PublicUsers
|
||||||
Subscriptions subscriptions.Subscriptions
|
Subscriptions subscriptions.Subscriptions
|
||||||
|
|
||||||
@ -55,13 +52,6 @@ type CoreServices struct {
|
|||||||
// Plugin interop
|
// Plugin interop
|
||||||
Bridge PluginBridge
|
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
|
// Core RPC services — pre-built bindings for CMS-provided services
|
||||||
CoreServiceBindings CoreServiceBindings
|
CoreServiceBindings CoreServiceBindings
|
||||||
ReviewSubmitter ReviewSubmitter
|
ReviewSubmitter ReviewSubmitter
|
||||||
|
|||||||
@ -53,40 +53,6 @@ type ModPlugin struct {
|
|||||||
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
|
// packed manifest (PluginManifest.data_dir); the .bnp reader also reads it
|
||||||
// straight from plugin.mod as a fallback. OFF by default.
|
// straight from plugin.mod as a fallback. OFF by default.
|
||||||
DataDir bool `toml:"data_dir,omitempty"`
|
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 {
|
type ModCompat struct {
|
||||||
|
|||||||
@ -1,90 +0,0 @@
|
|||||||
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 ""
|
|
||||||
}
|
|
||||||
@ -1,214 +0,0 @@
|
|||||||
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,8 +4,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@ -15,7 +13,6 @@ import (
|
|||||||
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
abiv1 "git.dev.alexdunmow.com/block/pluginsdk/abi/v1"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
"git.dev.alexdunmow.com/block/pluginsdk/ai"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/content"
|
"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/gating"
|
||||||
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -163,19 +160,6 @@ 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",
|
name: "content_get_page", wantMethod: "content.get_page",
|
||||||
resp: &abiv1.ContentGetPageResponse{Page: &abiv1.PageInfo{Id: idAuthor.String(), Slug: "about", Title: "About Us"}},
|
resp: &abiv1.ContentGetPageResponse{Page: &abiv1.PageInfo{Id: idAuthor.String(), Slug: "about", Title: "About Us"}},
|
||||||
@ -405,90 +389,6 @@ 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 ---
|
// --- users ---
|
||||||
{
|
{
|
||||||
name: "users_get_by_username", wantMethod: "users.get_by_username",
|
name: "users_get_by_username", wantMethod: "users.get_by_username",
|
||||||
@ -951,35 +851,6 @@ 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
|
// --- jobs.progress (WO-WZ-019; sent by the job dispatch path, not a
|
||||||
// CoreServices stub, so the case drives the transport directly) ---
|
// CoreServices stub, so the case drives the transport directly) ---
|
||||||
{
|
{
|
||||||
@ -1048,22 +919,6 @@ 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) {
|
func TestNilTransportFailsCleanly(t *testing.T) {
|
||||||
cs := NewCoreServices(nil)
|
cs := NewCoreServices(nil)
|
||||||
_, err := cs.Content.GetPage(context.Background(), "about")
|
_, err := cs.Content.GetPage(context.Background(), "about")
|
||||||
|
|||||||
@ -47,22 +47,6 @@ 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
|
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) {
|
func (s *contentStub) GetPost(ctx context.Context, slug string) (*content.PostInfo, error) {
|
||||||
resp := &abiv1.ContentGetPostResponse{}
|
resp := &abiv1.ContentGetPostResponse{}
|
||||||
if err := s.invoke(ctx, "get_post", &abiv1.ContentGetPostRequest{Slug: slug}, resp); err != nil {
|
if err := s.invoke(ctx, "get_post", &abiv1.ContentGetPostRequest{Slug: slug}, resp); err != nil {
|
||||||
|
|||||||
@ -34,7 +34,6 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
|
|||||||
Crypto: &cryptoStub{base{family: "crypto", call: call}},
|
Crypto: &cryptoStub{base{family: "crypto", call: call}},
|
||||||
Menus: &menusStub{base{family: "menus", call: call}},
|
Menus: &menusStub{base{family: "menus", call: call}},
|
||||||
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
|
Datasources: &datasourcesStub{base{family: "datasources", call: call}},
|
||||||
DataTables: &datatablesStub{base{family: "datatables", call: call}},
|
|
||||||
PublicUsers: &usersStub{base{family: "users", call: call}},
|
PublicUsers: &usersStub{base{family: "users", call: call}},
|
||||||
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
|
Subscriptions: &subscriptionsStub{base{family: "subscriptions", call: call}},
|
||||||
Media: &mediaStub{base{family: "media", call: call}},
|
Media: &mediaStub{base{family: "media", call: call}},
|
||||||
@ -48,6 +47,5 @@ func NewCoreServices(call CallFunc) plugin.CoreServices {
|
|||||||
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
|
EmbeddingService: &embeddingsStub{base{family: "embeddings", call: call}},
|
||||||
RAGService: NewRAGStub(call),
|
RAGService: NewRAGStub(call),
|
||||||
Provisioner: &provisionerStub{base{family: "provisioner", call: call}},
|
Provisioner: &provisionerStub{base{family: "provisioner", call: call}},
|
||||||
OutboundHTTP: &httpStub{base{family: "http", call: call}},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,102 +0,0 @@
|
|||||||
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(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
calcom:booking
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
B{"captchaEnabled":true,"eventTypeSlug":"30min","username":"alice"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
\
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"New"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idmissing
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaagoogle_place_idabc
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
g
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"google_place_id":"abc"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
h
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Whiteman Park"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
playgrounds
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
C
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaaplaygroundsPlaygrounds ¦
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaad
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
|
|
||||||
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"}
|
|
||||||
@ -1,2 +0,0 @@
|
|||||||
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb{"name":"Updated"}
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
b
|
|
||||||
$bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb$aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa{"name":"Updated"}
|
|
||||||
@ -1,4 +0,0 @@
|
|||||||
|
|
||||||
GEThttps://api.cal.com/v1/slots
|
|
||||||
Authorization
|
|
||||||
Bearer test
|
|
||||||
@ -1,3 +0,0 @@
|
|||||||
È"
|
|
||||||
Content-Type
|
|
||||||
application/json{"ok":true}"https://api.cal.com/v1/slots
|
|
||||||
@ -1,769 +0,0 @@
|
|||||||
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:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,229 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,782 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user