Embed transparent images with accessible source fallback and add safe artifact rebuilds with cache revalidation. Support generic block transform headers and document list, aggregate and render view modes.
- commit
- 835dc162e3dec3b7db2e5d3b4569be1c6e7273e3
- parent
- 2b6691190621b248e4edc624fb6a264905da7638
- author
- Dami <dami.etoile@gmail.com>
| A | docs/ | 150 | ++++++++++++++++++++++++++++++ |
| M | docs/ | 29 | ++++-- |
| M | internal/ | 30 | +++++- |
| M | internal/ | 21 | +++- |
| A | internal/ | 45 | +++++++++ |
| M | internal/ | 72 | ++++++++++++++- |
| M | internal/ | 15 | +++- |
| M | internal/ | 6 | +- |
| M | internal/ | 20 | +++- |
| M | internal/ | 2 | +- |
| M | internal/ | 14 | +++ |
| M | internal/ | 6 | +- |
| M | internal/ | 8 | +- |
| M | internal/ | 5 | +- |
| M | package.json | 3 | +- |
| A | scripts/ | 39 | ++++++++ |
@@ -0,0 +1,150 @@
1+# Reusable view definitions2+3+Status: Proposal. These definitions are not accepted by the current management API. [Custom views](views.md) describes the block transforms available today.4+5+A view selects relay data and presents a result. It can list events, summarize a set of events or render one event differently. A diagram renderer is one application of the third mode.6+7+## Three modes8+9+| Mode | Input and result | Examples |10+| --- | --- | --- |11+| `list` | A bounded event query becomes an ordered collection. | Articles by an author, open repository issues, a room feed. |12+| `aggregate` | A bounded set of events becomes one derived result. | Counts by kind, a calendar with RSVP totals, a weekly activity report. |13+| `render` | One event or an extracted part becomes a replacement or companion result. | Diagram blocks, a formatted event card, a rendered README. |14+15+The modes share selection, access, refresh and presentation concepts. They have different result identities and update rules. A scheduled render remains a set of independent renders; running it hourly does not make it an aggregate.16+17+## Shared fields18+19+| Field | Meaning |20+| --- | --- |21+| `version` | Definition format version. |22+| `name` | Stable owner-selected name. |23+| `mode` | `list`, `aggregate` or `render`. |24+| `source` | A Nostr filter and a bounded selection policy. |25+| `refresh` | Refresh on matching writes or on an interval. Manual refresh is always available. |26+| `audience` | Who can read the view: `public` or `members`. |27+| `presentation` | How the result appears: events, a table, structured data or an inline rendering. |28+29+`source.filter` uses the relay's existing Nostr filter semantics, including kinds, authors, IDs and tag filters, through its access-controlled query path. A source limit and deterministic ordering are explicit; equal timestamps use the relay's event ID ordering. A time window is separate from the refresh interval. `refresh.on` and `refresh.every_seconds` are mutually exclusive.30+31+Mode-specific fields are validated for that mode. A list does not require a transform URL. An aggregate does not require a language. A render does not have to produce an image.32+33+## Event list34+35+An article view needs a query and a presentation:36+37+```json38+{39+ "version": 1,40+ "name": "articles",41+ "mode": "list",42+ "source": {43+ "filter": { "kinds": [30023] },44+ "limit": 100,45+ "order": "newest"46+ },47+ "refresh": { "on": "write" },48+ "audience": "public",49+ "presentation": { "type": "events" }50+}51+```52+53+The result retains event references. Existing event components can render them without an external service or generated HTML. Event replacement, deletion and expiration update membership in the list.54+55+## Aggregate56+57+A report counts notes and articles written in the last seven days:58+59+```json60+{61+ "version": 1,62+ "name": "weekly-activity",63+ "mode": "aggregate",64+ "source": {65+ "filter": { "kinds": [1, 30023] },66+ "window_seconds": 604800,67+ "limit": 10000,68+ "order": "newest"69+ },70+ "transform": {71+ "type": "builtin",72+ "name": "count",73+ "group_by": ["kind"]74+ },75+ "refresh": { "every_seconds": 3600 },76+ "audience": "members",77+ "presentation": { "type": "table" }78+}79+```80+81+This produces one result for the selected set. Its response records the time range, source count and whether the selection was truncated. A partial selection must not be presented as a complete total. A table and a chart can present the same structured result without rerunning the aggregation.82+83+Built-in reducers cover common reports. An HTTP reducer can use the same mode only with an explicit, bounded event projection defining the fields sent to the service. It must not inherit the block endpoint's request contract.84+85+## Event rendering86+87+A diagram view selects events, extracts their fenced blocks and renders those blocks:88+89+```json90+{91+ "version": 1,92+ "name": "diagrams",93+ "mode": "render",94+ "source": {95+ "filter": { "kinds": [1, 30023, 30818] },96+ "limit": 500,97+ "order": "newest"98+ },99+ "input": {100+ "type": "fenced-blocks",101+ "from": "content",102+ "languages": ["mermaid", "dot", "d2"]103+ },104+ "transform": {105+ "type": "http",106+ "url": "https://diagram.zip/transform/blocks",107+ "connection": "diagram-renderer",108+ "options": { "appearance": "auto-transparent" }109+ },110+ "output": {111+ "types": ["image/svg+xml", "image/png"],112+ "max_bytes": 1048576113+ },114+ "refresh": { "on": "write" },115+ "audience": "public",116+ "presentation": {117+ "type": "inline",118+ "target": "input",119+ "fallback": "source"120+ }121+}122+```123+124+Languages belong to the fenced-block extractor. Image types belong to this renderer's output contract. Neither is a requirement for every view.125+126+Whole-event rendering uses an explicit field projection as its input and can return structured data or text for a registered presenter. Repository rendering uses an explicit `repo-readme` source adapter. Kind 30618 alone should not silently change event content into a Git file.127+128+Transform options belong to the chosen adapter. Diagram appearance and frame settings do not become relay-wide view fields. Credentials live in the named connection and are excluded from exported definitions.129+130+## Results and access131+132+Each mode has a distinct result identity:133+134+- A list identifies a query result and the referenced events.135+- An aggregate identifies the definition revision and the selected source snapshot or window.136+- A render identifies the definition revision, the input and the renderer revision. Reuse across events is allowed only when the renderer depends solely on the extracted input.137+138+Changing a filter, transform option, output type or renderer revision invalidates the affected results. Refresh replaces a result atomically only after the new result passes validation. A completed run cannot overwrite a newer definition or source snapshot. Readers can see when a result was produced and whether it is stale or failed.139+140+The view's audience cannot grant access to source data a reader could not otherwise read. Selection, presentation and HTTP serving enforce that rule. Public summaries of restricted data need a separate, explicit policy; choosing an aggregate mode must not publish private inputs. The source fields exported to an external transform are declared independently from the result's audience.141+142+Each presentation has a server-rendered baseline. Lists use event components, tables use defined columns, structured results use the JSON viewer, and inline renders retain source access. Arbitrary HTML, scripts and executable templates are outside this proposal.143+144+## Migration145+146+The existing flat custom-view definition maps to `mode: "render"`: `kinds` becomes the source filter, `languages` moves into the fenced-block input, and the URL becomes an HTTP transform. Existing API calls and artifact URLs remain valid during migration. An old definition that includes repository state needs an explicit README adapter when exported to the new format.147+148+The built-in article view is a candidate for `list`; calendar, zap and moderation summaries are candidates for `aggregate`. Their existing audience rules must remain intact. Presence is live state and needs a live source adapter rather than being forced into a stored event query.149+150+Use one catalog and one definition validator for the three modes. Keep execution and result handling specific to each mode. Validate the shared design with an event list, an aggregate report and a block render before replacing the current APIs.@@ -1,22 +1,24 @@
11 # Custom views22 3-A custom view renders the fenced code blocks a relay already holds. The owner names a set of event kinds and a set of fenced block languages, and points the view at an https transform they run. When a matching event arrives the relay posts the blocks in those languages to the transform, and keeps the SVG or PNG that comes back as a relay-signed artifact attached to the source event. The relay's pages then show the artifact in place of the code block.3+A custom view currently provides a block transform: it renders fenced code blocks the relay already holds. The owner names a set of event kinds and fenced block languages, and points the view at an HTTPS transform they run. When a matching event arrives, the relay posts the matching blocks to the transform and keeps the SVG or PNG it returns as a relay-signed artifact attached to the source event. The relay's pages show the artifact in place of the code block.44 5-Nothing about a view is built into the relay. It renders what its owner asked for, through a service the owner chose. A relay with no views behaves exactly as before, and a block whose artifact is missing, still queued or refused stays visible as code.5+Block transforms can support diagrams, charts or other code-based images. They do not define event lists, aggregate reports or alternate presentations of whole events. The built-in views listed by `listviews` produce signed summaries separately. See the [reusable view proposal](view-definitions.md) for a shared definition covering these uses.6+7+A relay with no block transforms keeps showing code. The source remains available beneath each image. If an image fails to load, the page opens its source when JavaScript is available; without JavaScript, open **Source** to read it.68 79 ## Define a view810 911 Call the `addcustomview` management method, or the `add_custom_view` MCP tool, as the owner:1012 1113 ```json12-{"method": "addcustomview", "params": [{"name": "diagrams", "kinds": [30818, 30023, 30618], "transform": "https://render.example/tiny", "languages": ["mermaid", "dot", "d2"], "trigger": "write", "audience": "public"}]}14+{"method": "addcustomview", "params": [{"name": "diagrams", "kinds": [30818, 30023, 30618], "transform": "https://render.example/transform/blocks", "languages": ["mermaid", "dot", "d2"], "trigger": "write", "audience": "public"}]}1315 ```1416 1517 | Field | Meaning |1618 | --- | --- |1719 | `name` | 1 to 32 lowercase letters, digits or hyphens. Names the view and its artifact paths. |1820 | `kinds` | 1 to 16 event kinds to watch. Kind 30618 sends the repository README at its head. |19-| `transform` | An https URL on a public host. |21+| `transform` | An HTTPS URL on a public host. |2022 | `languages` | 1 to 32 fenced block languages, lowercase. Only these blocks are sent. |2123 | `trigger` | `write` sends each matching event as it arrives, `hourly` sends the newest ones every hour. Defaults to `write`. |2224 | `audience` | `public` publishes the artifacts, `members` keeps them to members. Defaults to `public`. |@@ -37,19 +39,23 @@ A view posts one request for each source event, carrying only the blocks:
3739 3840 | Header | Value |3941 | --- | --- |40-| `X-Tiny-View` | The view name. |41-| `X-Tiny-Relay` | The relay's public URL. |42-| `X-Tiny-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the body under the view's secret. |42+| `X-Transform-View` | The view name. |43+| `X-Transform-Relay` | The relay's public URL. |44+| `X-Transform-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the body under the view's secret. |4345 4446 Verify the signature before rendering anything. The relay waits 30 seconds for an answer.4547 48+The relay also sends the equivalent `X-Tiny-View`, `X-Tiny-Relay` and `X-Tiny-Signature` headers for existing transforms. New integrations should use the `X-Transform-*` headers.49+4650 ## What the transform answers4751 4852 ```json49-{"artifacts": [{"block": 2, "type": "svg", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]}53+{"artifacts": [{"block": 2, "type": "image/svg+xml", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]}5054 ```5155 52-Each artifact names the block it rendered, its `type`, either `svg` or `png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are checked before they are stored: script elements, event handlers and external references are rejected rather than stripped, and the whole artifact is refused. A block listed under `errors`, or left out of the answer entirely, keeps showing its code.56+Each artifact names the block it rendered, its `type`, either `image/svg+xml` or `image/png`, and its `body`, the SVG source or a base64 PNG. `engine` is optional and is recorded on the artifact. An artifact larger than the view's `max_bytes` is refused. SVG bodies are checked before they are stored: script elements, event handlers, foreign objects and external references are rejected rather than stripped, and the whole artifact is refused. A block listed under `errors`, or left out of the answer entirely, keeps showing its code.57+58+SVG artifacts must include their own colors and background rules. Page styles do not reach into the SVG document. Diagramzip's `/transform/blocks` endpoint defaults to `auto-transparent`, which omits the canvas and includes light and dark palettes. The diagram follows the page's color scheme. After changing a transform's styling, rebuild the stored artifacts to apply the change.5359 5460 ## Artifacts5561 @@ -57,7 +63,7 @@ An artifact is a relay-signed kind 30078 record addressed `bind.ws/view/<name>/<
5763 5864 Artifacts follow their sources. An artifact expires when its last source expires, and one that outlives every source that referred to it is deleted. Editing an event detaches the blocks it no longer carries, and the artifact is dropped once nothing points at it. Removing a view deletes every artifact it produced.5965 60-Each artifact is served at `/views/<name>/<hash>.svg` or `.png` under a sandbox policy that blocks scripts and outside loads. A members-only view answers only members; a public one follows the relay's read rule. The relay's pages embed the artifact with the code block as its fallback, so a reader whose browser refuses the artifact still sees the source.66+Each artifact is served at `/views/<name>/<hash>` with its stored media type, under a sandbox policy that blocks scripts and outside loads. The `.svg` and `.png` URLs remain available. A members-only view answers only members; a public one follows the relay's read rule. The relay's pages embed the artifact as an image with a native **Source** disclosure. Browsers revalidate images so a rebuild can replace them.6167 6268 ## Failures6369 @@ -73,5 +79,6 @@ A response outside 2xx, a timeout or a connection failure counts as a failure. T
7379 | `pausecustomview <name>` | Stops transform requests. The artifacts stay. |7480 | `resumecustomview <name>` | Resumes the view and clears the failure count. |7581 | `runcustomview <name>` | Queues a backfill over the newest 500 events of the view's kinds. Blocks that already have an artifact are reused. |82+| `runcustomview {"name": "diagrams", "refresh": true}` | Rebuilds artifacts for the newest 500 matching events. Existing artifacts stay available until a valid replacement arrives. |7683 77-The **Manage > Views** page lists each view with its state, last run and failure count, and buttons to pause, resume, run or remove it. The same controls are available over MCP; see [MCP](mcp.md).84+The **Manage > Views** page lists each view with its state, last run and failure count, and buttons to pause, resume, run, rebuild or remove it. **Run now** fills missing artifacts; **Rebuild** also refreshes existing ones. The same controls are available over MCP; see [MCP](mcp.md).@@ -337,7 +337,8 @@ func (t *Tenant) changeCustomView(ctx context.Context, actor, method string, par
337337 if !view.Enabled {338338 return nil, errors.New("invalid: resume the view before running it")339339 }340- queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10))340+ refresh := customViewRefreshParam(params)341+ queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10), refresh)341342 if err != nil {342343 return nil, err343344 }@@ -403,6 +404,19 @@ func customViewNameParam(params []json.RawMessage) string {
403404 return strings.TrimSpace(options.Name)404405 }405406 407+func customViewRefreshParam(params []json.RawMessage) bool {408+ if len(params) == 0 {409+ return false410+ }411+ var options struct {412+ Refresh bool `json:"refresh"`413+ }414+ if err := json.Unmarshal(params[0], &options); err != nil {415+ return false416+ }417+ return options.Refresh418+}419+406420 func (t *Tenant) customViewByName(ctx context.Context, name string) (customView, error) {407421 rows, err := t.customViewRows(ctx, name)408422 if err != nil {@@ -518,7 +532,7 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
518532 if e.Kind != event.KIND_REPO_STATE && len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {519533 continue520534 }521- intents = append(intents, t.viewIntent(view, e, ""))535+ intents = append(intents, t.viewIntent(view, e, "", false))522536 }523537 if len(intents) == 0 {524538 return@@ -536,8 +550,9 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
536550 // viewIntent is the transform intent for one source. The event travels in537551 // the payload; run makes a backfill or an hourly pass distinct from the538552 // write-time intent for the same event.539-func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.Intent {540- payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run})553+func (t *Tenant) viewIntent(view customView, e event.Event, run string, refresh ...bool) storage.Intent {554+ force := len(refresh) > 0 && refresh[0]555+ payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run, Refresh: force})541556 id := e.ID542557 if run != "" {543558 id += "@" + run@@ -556,7 +571,8 @@ func (t *Tenant) statePending(ctx context.Context, id string) bool {
556571 // queueCustomViewBackfill queues the newest sources of a view's kinds, at557572 // most 500, written since the given time. Repository states are queued558573 // whether or not their README has blocks; the handler reads it.559-func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string) (int, error) {574+func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string, refresh ...bool) (int, error) {575+ force := len(refresh) > 0 && refresh[0]560576 rows, err := t.store.Query(ctx, event.Filter{Kinds: view.Kinds, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: viewBackfillLimit})561577 if err != nil {562578 return 0, err@@ -573,7 +589,7 @@ func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, s
573589 } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {574590 continue575591 }576- intents = append(intents, t.viewIntent(view, e, run))592+ intents = append(intents, t.viewIntent(view, e, run, force))577593 }578594 if len(intents) == 0 {579595 return 0, nil@@ -599,7 +615,7 @@ func (t *Tenant) tickCustomViews(ctx context.Context, now int64) error {
599615 if view.LastRunAt == 0 {600616 since = 0601617 }602- queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10))618+ queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10), false)603619 if err != nil {604620 return err605621 }@@ -1,10 +1,12 @@
11 package daemon22 3-// GET /views/<name>/<hash>.<svg|png> serves a custom view artifact under a3+// GET /views/<name>/<hash>[.<svg|png>] serves a custom view artifact under a44 // sandbox policy. A members-only view answers only members; a public one55 // follows the relay's read rule.66 77 import (8+ "crypto/sha256"9+ "encoding/hex"810 "net/http"911 "strconv"1012 "strings"@@ -33,8 +35,8 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {
3335 return3436 }3537 name := parts[0]36- hash, extension, _ := strings.Cut(parts[1], ".")37- if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (extension != "svg" && extension != "png") {38+ hash, extension, hasExtension := strings.Cut(parts[1], ".")39+ if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (hasExtension && extension != "svg" && extension != "png") {3840 http.NotFound(w, r)3941 outcome = "invalid"4042 return@@ -56,7 +58,7 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {
5658 return5759 }5860 wantType := map[string]string{"svg": "image/svg+xml", "png": "image/png"}[extension]59- if record.Type != wantType {61+ if extension != "" && record.Type != wantType {6062 http.NotFound(w, r)6163 outcome = "invalid"6264 return@@ -91,12 +93,19 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {
9193 w.Header().Set("Content-Length", strconv.Itoa(len(body)))9294 w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")9395 w.Header().Set("X-Content-Type-Options", "nosniff")96+ etagBytes := sha256.Sum256(body)97+ etag := `"` + hex.EncodeToString(etagBytes[:]) + `"`98+ w.Header().Set("ETag", etag)9499 if record.Audience == "members" {95- w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")100+ w.Header().Set("Cache-Control", "private, no-cache")96101 } else {97- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")102+ w.Header().Set("Cache-Control", "public, no-cache")98103 }99104 outcome = "ok"105+ if r.Header.Get("If-None-Match") == etag {106+ w.WriteHeader(http.StatusNotModified)107+ return108+ }100109 if r.Method == http.MethodHead {101110 w.WriteHeader(http.StatusOK)102111 return@@ -0,0 +1,45 @@
1+package daemon2+3+import (4+ "net/http"5+ "net/http/httptest"6+ "strings"7+ "testing"8+ "time"9+10+ "github.com/FelineStateMachine/tinyrelay/internal/views"11+)12+13+func TestCustomViewMediaPathServesStoredType(t *testing.T) {14+ for _, mediaType := range []string{"image/svg+xml", "image/png"} {15+ t.Run(mediaType, func(t *testing.T) {16+ tenant, server, _ := viewTenant(t, []int{1}, nil)17+ artifact := svgArtifact(0)18+ artifact["type"] = mediaType19+ if mediaType == "image/png" {20+ artifact["body"] = "iVBORw0KGgoAAAANSUhEUg=="21+ }22+ server.answer(http.StatusOK, artifact)23+ e := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\ngraph TD; a-->b;\n```")24+ if err := publishAs(t, tenant, e); err != nil {25+ t.Fatal(err)26+ }27+ runPendingView(t, tenant, "diagrams", e.ID)28+ _, image, ok := strings.Cut(views.Figure("diagrams", "mermaid", "graph TD; a-->b;"), `<img src="`)29+ if !ok {30+ t.Fatal("custom view has no image")31+ }32+ path, _, _ := strings.Cut(image, `"`)33+ res := getArtifact(t, tenant, path, "")34+ if res.Code != http.StatusOK || res.Header().Get("Content-Type") != mediaType {35+ t.Fatalf("media response: %d %s", res.Code, res.Body.String())36+ }37+ request := httptest.NewRequest(http.MethodHead, "http://127.0.0.1:8080"+path, nil)38+ head := httptest.NewRecorder()39+ tenant.ServeHTTP(head, request)40+ if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("ETag") != res.Header().Get("ETag") {41+ t.Fatalf("media HEAD: %d %s", head.Code, head.Body.String())42+ }43+ })44+ }45+}@@ -367,6 +367,11 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {
367367 if headers.Get("Content-Type") != "application/json" || headers.Get("X-Tiny-View") != "diagrams" || headers.Get("X-Tiny-Relay") != "http://127.0.0.1:8080" || headers.Get("X-Tiny-Signature") != "sha256="+hex.EncodeToString(mac.Sum(nil)) {368368 t.Fatalf("headers = %v", headers)369369 }370+ for _, field := range []string{"View", "Relay", "Signature"} {371+ if headers.Get("X-Transform-"+field) != headers.Get("X-Tiny-"+field) {372+ t.Fatalf("canonical transform header %s differs from its legacy value", field)373+ }374+ }370375 var request map[string]any371376 if err := json.Unmarshal(body, &request); err != nil {372377 t.Fatal(err)@@ -414,14 +419,21 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {
414419 415420 // The route serves each artifact under a sandbox, by its own type.416421 res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "")417- if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {422+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Content-Type") != "image/svg+xml" || res.Header().Get("Content-Security-Policy") != "sandbox; default-src 'none'; style-src 'unsafe-inline'" || res.Header().Get("X-Content-Type-Options") != "nosniff" || res.Header().Get("Cache-Control") != "public, no-cache" || res.Header().Get("ETag") == "" {418423 t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String())419424 }425+ conditional := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/views/diagrams/"+svgHash+".svg", nil)426+ conditional.Header.Set("If-None-Match", res.Header().Get("ETag"))427+ conditionalResponse := httptest.NewRecorder()428+ tenant.ServeHTTP(conditionalResponse, conditional)429+ if conditionalResponse.Code != http.StatusNotModified {430+ t.Fatalf("conditional artifact: %d %v", conditionalResponse.Code, conditionalResponse.Header())431+ }420432 res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "")421433 if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" {422434 t.Fatalf("png route: %d %v", res.Code, res.Header())423435 }424- for _, path := range []string{"/views/diagrams/" + pngHash + ".svg", "/views/diagrams/" + strings.Repeat("0", 64) + ".svg", "/views/other/" + svgHash + ".svg", "/views/diagrams/" + svgHash + ".gif", "/views/diagrams/" + svgHash, "/views/diagrams"} {436+ for _, path := range []string{"/views/diagrams/" + pngHash + ".svg", "/views/diagrams/" + strings.Repeat("0", 64) + ".svg", "/views/other/" + svgHash + ".svg", "/views/diagrams/" + svgHash + ".gif", "/views/diagrams/" + svgHash + ".", "/views/diagrams"} {425437 if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound {426438 t.Fatalf("%s: %d", path, res.Code)427439 }@@ -562,6 +574,60 @@ func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) {
562574 }563575 }564576 577+func queueViewRebuild(t *testing.T, tenant *Tenant, source string) work.Intent {578+ t.Helper()579+ params := viewParams(map[string]any{"name": "diagrams", "refresh": true})580+ if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "runcustomview", params); err != nil {581+ t.Fatal(err)582+ }583+ for _, intent := range pendingViewIntents(t, tenant, "diagrams", source) {584+ var payload viewPayload585+ if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil {586+ t.Fatal(err)587+ }588+ if payload.Attempt == 1 {589+ if !payload.Refresh {590+ t.Fatal("queued rebuild lost refresh flag")591+ }592+ return intent593+ }594+ }595+ t.Fatal("no rebuild was queued")596+ return work.Intent{}597+}598+599+func TestCustomViewRebuildReplacesOnlyAfterSuccess(t *testing.T) {600+ tenant, server, _ := viewTenant(t, []int{1}, nil)601+ note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```")602+ if err := publishAs(t, tenant, note); err != nil {603+ t.Fatal(err)604+ }605+ server.answer(http.StatusOK, svgArtifact(0))606+ runPendingView(t, tenant, "diagrams", note.ID)607+ path := views.Path("diagrams", views.Hash("mermaid", "A"), "")608+ before := getArtifact(t, tenant, path, "")609+ if before.Code != http.StatusOK {610+ t.Fatalf("initial artifact: %d", before.Code)611+ }612+ server.answer(http.StatusInternalServerError)613+ runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID))614+ afterFailure := getArtifact(t, tenant, path, "")615+ if afterFailure.Body.String() != before.Body.String() || afterFailure.Header().Get("ETag") != before.Header().Get("ETag") {616+ t.Fatal("failed rebuild changed the stored artifact")617+ }618+ replacement := svgArtifact(0)619+ replacement["body"] = strings.Replace(testSVG, "<rect", `<rect fill="#334155"`, 1)620+ server.answer(http.StatusOK, replacement)621+ runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID))622+ after := getArtifact(t, tenant, path, "")623+ if after.Body.String() != replacement["body"] || after.Header().Get("ETag") == before.Header().Get("ETag") {624+ t.Fatal("successful rebuild did not replace and revalidate the artifact")625+ }626+ if server.count() != 3 {627+ t.Fatalf("transform requests = %d", server.count())628+ }629+}630+565631 func TestCustomViewTransformRetriesThenPauses(t *testing.T) {566632 tenant, server, _ := viewTenant(t, []int{1}, nil)567633 ctx := context.Background()@@ -729,7 +795,7 @@ func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) {
729795 }730796 for _, secret := range []string{testMemberSecret, testOwnerSecret} {731797 res := getArtifact(t, tenant, path, secret)732- if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, max-age=31536000, immutable" {798+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, no-cache" || res.Header().Get("ETag") == "" {733799 t.Fatalf("member: %d %v", res.Code, res.Header())734800 }735801 }@@ -40,6 +40,7 @@ type viewPayload struct {
4040 Event event.Event `json:"event"`4141 Attempt int `json:"attempt"`4242 Run string `json:"run,omitempty"`43+ Refresh bool `json:"refresh,omitempty"`4344 }4445 4546 // viewRequest is the body posted to a transform: only the blocks, with the@@ -227,12 +228,18 @@ func (t *Tenant) handleViewTransform(ctx context.Context, intent work.Intent) (e
227228 blocks = views.Matching(blocks, view.Languages)228229 source := viewSourceKey(payload.Event)229230 expires := event.Expiration(payload.Event)230- // Attach the blocks that already have an artifact and keep the rest.231+ // A rebuild deliberately bypasses existing artifacts. The replacement is232+ // written only after the transform succeeds, so a failed rebuild preserves233+ // the previous artifact.231234 var pending []views.Block232235 hashes := make([]string, 0, len(blocks))233236 for _, block := range blocks {234237 hash := views.Hash(block.Lang, block.Source)235238 hashes = append(hashes, hash)239+ if payload.Refresh {240+ pending = append(pending, block)241+ continue242+ }236243 exists, err := t.attachArtifact(ctx, view, hash, source, block.Index, expires)237244 if err != nil {238245 return err@@ -350,7 +357,11 @@ func (t *Tenant) postViewTransform(ctx context.Context, view customView, e event
350357 request.Header.Set("Accept", "application/json")351358 request.Header.Set("X-Tiny-View", view.Name)352359 request.Header.Set("X-Tiny-Relay", t.publicURL)353- request.Header.Set("X-Tiny-Signature", callbackSignature(view.secret, body))360+ signature := callbackSignature(view.secret, body)361+ request.Header.Set("X-Tiny-Signature", signature)362+ request.Header.Set("X-Transform-View", view.Name)363+ request.Header.Set("X-Transform-Relay", t.publicURL)364+ request.Header.Set("X-Transform-Signature", signature)354365 request.Header.Set("User-Agent", "tinyrelay")355366 response, err := t.viewHTTPClient().Do(request)356367 if err != nil {@@ -167,7 +167,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
167167 add("add_callback", "Register an https URL that receives a POST with each new event matching filter that your key may see. The filter takes kinds (required), authors, #a, #e, #p and #h with at most 8 values each. The answer carries the callback id and, once, the secret used for the X-Tiny-Signature header; keep it. Members and agents may hold up to the relay's callback allowance, 4 by default.", mcp.Object(map[string]any{"url": map[string]any{"type": "string", "description": "An https URL on a public host."}, "filter": map[string]any{"type": "object", "description": "NIP-01 filter subset: kinds, authors, #a, #e, #p, #h.", "minProperties": 1}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "url", "filter"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {168168 return t.mcpManage(ctx, call, "addcallback", call.Arguments)169169 })170- add("add_custom_view", "Define a custom view: the relay POSTs the fenced code blocks of matching events, in the listed languages, to an https transform and keeps the SVG or PNG artifacts it returns as relay-signed events shown in place of the blocks. The answer carries the secret once, used for the X-Tiny-Signature header; keep it. Owner only.", mcp.Object(map[string]any{"name": map[string]any{"type": "string", "pattern": "^[a-z0-9-]{1,32}$", "description": "View name: 1 to 32 lowercase letters, digits or hyphens."}, "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}, "minItems": 1, "maxItems": viewKindMax, "description": "Event kinds the view watches; 30618 sends the repository README at its head."}, "transform": map[string]any{"type": "string", "description": "An https URL on a public host that renders the blocks."}, "trigger": map[string]any{"type": "string", "enum": []string{"write", "hourly"}, "description": "write sends each matching event as it arrives; hourly sends the newest ones every hour."}, "audience": map[string]any{"type": "string", "enum": []string{"public", "members"}, "description": "Who may see the artifacts."}, "languages": map[string]any{"type": "array", "items": map[string]any{"type": "string", "minLength": 1, "maxLength": viewLanguageLength}, "minItems": 1, "maxItems": viewLanguageMax, "description": "Fenced block languages the view renders, lowercase."}, "max_bytes": map[string]any{"type": "integer", "minimum": 1, "maximum": viewMaxBytesCap, "description": "Largest artifact accepted, in bytes. 1 MiB when omitted, 4 MiB at most."}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "name", "kinds", "transform", "languages"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {170+ add("add_custom_view", "Define a custom view: the relay POSTs the fenced code blocks of matching events, in the listed languages, to an https transform and keeps the SVG or PNG artifacts it returns as relay-signed events shown in place of the blocks. The answer carries the secret once, used for the X-Transform-Signature header; keep it. Owner only.", mcp.Object(map[string]any{"name": map[string]any{"type": "string", "pattern": "^[a-z0-9-]{1,32}$", "description": "View name: 1 to 32 lowercase letters, digits or hyphens."}, "kinds": map[string]any{"type": "array", "items": map[string]any{"type": "integer", "minimum": 0, "maximum": 65535}, "minItems": 1, "maxItems": viewKindMax, "description": "Event kinds the view watches; 30618 sends the repository README at its head."}, "transform": map[string]any{"type": "string", "description": "An https URL on a public host that renders the blocks."}, "trigger": map[string]any{"type": "string", "enum": []string{"write", "hourly"}, "description": "write sends each matching event as it arrives; hourly sends the newest ones every hour."}, "audience": map[string]any{"type": "string", "enum": []string{"public", "members"}, "description": "Who may see the artifacts."}, "languages": map[string]any{"type": "array", "items": map[string]any{"type": "string", "minLength": 1, "maxLength": viewLanguageLength}, "minItems": 1, "maxItems": viewLanguageMax, "description": "Fenced block languages the view renders, lowercase."}, "max_bytes": map[string]any{"type": "integer", "minimum": 1, "maximum": viewMaxBytesCap, "description": "Largest artifact accepted, in bytes. 1 MiB when omitted, 4 MiB at most."}, "secret": map[string]any{"type": "string", "minLength": callbackSecretMin, "maxLength": callbackSecretMax, "description": "Optional shared secret, 16 to 128 printable ASCII characters. Generated when omitted."}}, "name", "kinds", "transform", "languages"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {171171 return t.mcpManage(ctx, call, "addcustomview", call.Arguments)172172 })173173 add("remove_custom_view", "Delete a custom view and every artifact it produced. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {@@ -179,8 +179,8 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
179179 add("resume_custom_view", "Resume a paused custom view and clear its failure count. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpControls, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {180180 return t.mcpManage(ctx, call, "resumecustomview", call.String("name"))181181 })182- add("run_custom_view", "Queue a backfill of a custom view over the newest 500 events of its kinds. Blocks that already have an artifact are reused. Owner only.", mcp.Object(map[string]any{"name": mcpID}, "name"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {183- return t.mcpManage(ctx, call, "runcustomview", call.String("name"))182+ add("run_custom_view", "Queue a backfill of a custom view over the newest 500 events of its kinds. Blocks that already have an artifact are reused unless refresh is true, which rebuilds them from the transform. Owner only.", mcp.Object(map[string]any{"name": mcpID, "refresh": map[string]any{"type": "boolean", "description": "Rebuild existing artifacts from the transform."}}, "name"), mcpChanges, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {183+ return t.mcpManage(ctx, call, "runcustomview", call.Arguments)184184 })185185 add("remove_callback", "Delete a callback by id. The callback's owner, the relay owner and moderators may do this.", mcp.Object(map[string]any{"id": mcpID}, "id"), mcpSettings, func(ctx context.Context, call mcp.Call) (mcp.Result, error) {186186 return t.mcpManage(ctx, call, "removecallback", call.String("id"))@@ -92,19 +92,27 @@ var HashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
9292 9393 // Path is the address an artifact is served at.9494 func Path(view, hash, extension string) string {95- return "/views/" + view + "/" + hash + "." + extension95+ path := "/views/" + view + "/" + hash96+ if extension != "" {97+ path += "." + extension98+ }99+ return path96100 }97101 98-// Figure is the markup for a block a custom view renders: the artifact as99-// an object with the escaped code block inside it, so a missing or failed100-// artifact shows the code without any script.102+// Figure is the markup for a block a custom view renders. The image remains103+// transparent and follows the page color scheme, while the native details104+// disclosure keeps the source available without requiring script.101105 func Figure(view, lang, source string) string {102106 var b strings.Builder103- b.WriteString(`<figure data-view="` + html.EscapeString(view) + `"><object data="` + Path(view, Hash(lang, source), "svg") + `" type="image/svg+xml"><pre><code`)107+ alt := "Rendered view"108+ if lang != "" {109+ alt = "Rendered " + lang + " block"110+ }111+ b.WriteString(`<figure data-view="` + html.EscapeString(view) + `"><view-artifact><img src="` + Path(view, Hash(lang, source), "") + `" alt="` + html.EscapeString(alt) + `"><details><summary>Source</summary><pre><code`)104112 if lang != "" {105113 b.WriteString(` data-lang="` + html.EscapeString(lang) + `"`)106114 }107- b.WriteString(">" + html.EscapeString(source) + "</code></pre></object></figure>\n")115+ b.WriteString(">" + html.EscapeString(source) + "</code></pre></details></view-artifact></figure>\n")108116 return b.String()109117 }110118 @@ -64,7 +64,7 @@ func TestHashCoversLanguageAndSource(t *testing.T) {
6464 func TestFigureEmbedsTheArtifactWithTheCodeAsFallback(t *testing.T) {6565 got := Figure("diagrams", "mermaid", "graph TD;\n A-->B & <C>;")6666 hash := Hash("mermaid", "graph TD;\n A-->B & <C>;")67- want := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></object></figure>` + "\n"67+ want := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></details></view-artifact></figure>` + "\n"6868 if got != want {6969 t.Fatalf("figure =\n%s\nwant\n%s", got, want)7070 }@@ -20,6 +20,9 @@
2020 // Renders JSON as tables, lists and definition lists, keeping the raw2121 // text in a <details>. Server pages seed it with a <pre>; scripts set .value.2222 // Tables sit inside a <scroll-box> so wide rows scroll sideways.23+// <view-artifact>24+// Shows a custom view image and keeps its fenced source in a native25+// disclosure when the image cannot be loaded.2326 // <nostr-key hex="…">2427 // Shortened key with the full hex as title; click copies it.2528 // <file-mirror action="/mirror">@@ -181,6 +184,16 @@
181184 }182185 }183186 187+ class ViewArtifact extends HTMLElement {188+ connectedCallback() {189+ const image = this.querySelector("img"), source = this.querySelector("details");190+ if (!image || !source) return;191+ const failed = () => { image.hidden = true; source.open = true; };192+ image.addEventListener("error", failed, {once: true});193+ if (image.complete && image.naturalWidth === 0) failed();194+ }195+ }196+184197 // ViewForm adds a custom view: it composes the addcustomview object from185198 // its fields and shows the returned secret once, since the relay never186199 // shows it again.@@ -1715,6 +1728,7 @@
17151728 ensureModules();17161729 17171730 customElements.define("rpc-form", RpcForm);1731+ customElements.define("view-artifact", ViewArtifact);17181732 customElements.define("view-form", ViewForm);17191733 customElements.define("connect-card", ConnectCard);17201734 customElements.define("connect-list", ConnectList);@@ -46,7 +46,7 @@ func (b *viewsBackend) Query(ctx context.Context, method string, params []json.R
4646 const mermaidSource = "graph TD;\n A-->B & <C>;"4747 4848 func mermaidFigure(view string) string {49- return `<figure data-view="` + view + `"><object data="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></object></figure>`49+ return `<figure data-view="` + view + `"><view-artifact><img src="/views/` + view + `/` + views.Hash("mermaid", mermaidSource) + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A-->B & <C>;</code></pre></details></view-artifact></figure>`5050 }5151 5252 func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) {@@ -100,9 +100,9 @@ func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {
100100 t.Fatalf("repo home: %d %s", recorder.Code, body)101101 }102102 hash := views.Hash("mermaid", "graph TD; a-->b;")103- figure := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></object></figure>`103+ figure := `<figure data-view="diagrams"><view-artifact><img src="/views/diagrams/` + hash + `" alt="Rendered mermaid block"><details><summary>Source</summary><pre><code data-lang="mermaid">graph TD; a-->b;</code></pre></details></view-artifact></figure>`104104 wantAll(t, "repo home", body, figure, `href="/repo?owner=`)105- if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<object data="/r/work/views/diagrams/`+hash+`.svg"`) {105+ if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<img src="/r/work/views/diagrams/`+hash+`"`) {106106 t.Fatalf("tenant prefix missed the object address: %s", prefixed)107107 }108108 // A backend without the summary keeps code blocks as code.@@ -246,8 +246,8 @@
246246 <h1>Views</h1>247247 <rpc-form method="listviews"><button>List views</button></rpc-form>248248 <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form>249-{{if .Owner}}<h3>Custom views</h3>250-<p>A custom view sends fenced code blocks to a transform you run and keeps what comes back as relay-signed artifacts shown in place of the code.</p>249+{{if .Owner}}<h3>Block transforms</h3>250+<p>Block transforms render fenced code as images. Choose the event kinds and languages to send to your rendering service.</p>251251 {{if .CustomViews}}<table id="custom-views">252252 <thead><tr><th>name</th><th>kinds</th><th>languages</th><th>trigger</th><th>audience</th><th>state</th><th>last run</th><th></th></tr></thead>253253 <tbody>{{range .CustomViews}}{{$v := asMap .}}<tr id="view-{{str (index $v "name")}}">@@ -258,14 +258,14 @@
258258 <td>{{str (index $v "audience")}}</td>259259 <td data-state="{{viewState $v}}">{{viewState $v}}{{with str (index $v "lastStatus")}} | {{.}}{{end}}</td>260260 <td>{{with index $v "lastRun"}}{{when .}}{{else}}never{{end}}</td>261-<td>{{if eq (viewState $v) "paused"}}<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Pause</button></rpc-form><rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Run now</button></rpc-form>{{end}}<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Remove</button></rpc-form></td>261+<td>{{if eq (viewState $v) "paused"}}<rpc-form method="resumecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Resume</button></rpc-form>{{else}}<rpc-form method="pausecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Pause</button></rpc-form><rpc-form method="runcustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Run now</button></rpc-form><rpc-form method="runcustomview" refresh><input type="hidden" name="param" value='{"name":"{{str (index $v "name")}}","refresh":true}'><button>Rebuild</button></rpc-form>{{end}}<rpc-form method="removecustomview" refresh><input type="hidden" name="param" value="{{str (index $v "name")}}"><button>Remove</button></rpc-form></td>262262 </tr>{{end}}</tbody>263263 </table>{{else}}<p id="custom-views-empty">No custom views yet.</p>{{end}}264264 <view-form method="addcustomview">265265 <label>Name <input name="view-name" pattern="[a-z0-9-]{1,32}" placeholder="diagrams" required></label>266266 <label>Kinds <input name="view-kinds" placeholder="1, 30023, 30618" required></label>267267 <label>Languages <input name="view-languages" placeholder="mermaid, dot" required></label>268-<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/tiny" required></label>268+<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/transform/blocks" required></label>269269 <label>Trigger <select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select></label>270270 <label>Audience <select name="view-audience"><option value="public">public</option><option value="members">members</option></select></label>271271 <label>Max artifact bytes <input name="view-max-bytes" type="number" min="1" max="4194304" placeholder="1048576"></label>@@ -4,6 +4,7 @@
44 html[data-theme=dark]{--paper:#171614;--ink:#d9d6cf;--muted:#8d887c;--rail:#1e1d1a;--line:#2c2a26;--accent:#8fc27a;--soft:#22301f;--hover:#242320;--add:#223521;--del:#3a2a24;--alert:#e08a7a;--g0:#26251f;--g1:#2a3d24;--g2:#3b5a2f;--g3:#4f7d3d;--g4:#8fc27a;--bark:#d3a15c;--w1:#d3a15c;--w2:#9cc48a;--w3:#6ea55a;--w4:#d98a5c}55 @media(prefers-color-scheme:dark){html:not([data-theme=light]){--paper:#171614;--ink:#d9d6cf;--muted:#8d887c;--rail:#1e1d1a;--line:#2c2a26;--accent:#8fc27a;--soft:#22301f;--hover:#242320;--add:#223521;--del:#3a2a24;--alert:#e08a7a;--g0:#26251f;--g1:#2a3d24;--g2:#3b5a2f;--g3:#4f7d3d;--g4:#8fc27a;--bark:#d3a15c;--w1:#d3a15c;--w2:#9cc48a;--w3:#6ea55a;--w4:#d98a5c}}66 7+html{color-scheme:light}html[data-theme=dark]{color-scheme:dark}@media(prefers-color-scheme:dark){html:not([data-theme=light]){color-scheme:dark}}78 html,body{margin:0;padding:0;height:100%;overflow:hidden;overscroll-behavior:none}89 body{font:14px/1.45 var(--sans);color:var(--ink);background:var(--paper)}910 a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}@@ -161,8 +162,8 @@ view-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-w
161162 view-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}162163 view-form label input,view-form label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}163164 view-form [role=alert]{color:var(--alert);font:13px var(--sans);margin:8px 0 4px}view-form label[data-secret] input{min-width:320px}164-/* figure[data-view]: an artifact a custom view rendered; the code inside the object shows when the artifact is missing. */165-figure[data-view]{margin:12px 0}figure[data-view] object{display:block;max-width:100%}figure[data-view] pre{margin:0}165+/* figure[data-view]: a transparent artifact with a native source disclosure. */166+figure[data-view]{margin:12px 0}view-artifact{display:block}view-artifact img{display:block;max-width:100%;height:auto;background:transparent}view-artifact img[hidden]{display:none}view-artifact details{margin-top:6px}view-artifact details>summary{cursor:pointer;color:var(--muted);font:12px var(--mono)}view-artifact pre{margin:6px 0 0}166167 /* agent-callback: one registered callback on an agent card, with its controls. */167168 agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit}168169 agent-callback rpc-form{display:inline-block;border:0;padding:0;margin:0 0 0 6px;vertical-align:middle}agent-callback rpc-form output:empty{display:none}agent-callback button{font-size:12px;padding:1px 6px}@@ -15,7 +15,8 @@
1515 "test:wiki": "node --test scripts/wiki-compose.test.mjs",1616 "test:rooms": "node --test scripts/rooms.test.mjs",1717 "test:profile": "node --test scripts/profile-form.test.mjs",18- "test:page-link": "node --test scripts/page-link.test.mjs"18+ "test:page-link": "node --test scripts/page-link.test.mjs",19+ "test:view-artifact": "node --test scripts/view-artifact.test.mjs"1920 },2021 "devDependencies": {2122 "@noble/hashes": "2.4.0",@@ -0,0 +1,39 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+6+const source = fs.readFileSync("internal/webui/components.js", "utf8");7+const classSource = source.slice(source.indexOf(" class ViewArtifact"), source.indexOf(" // ViewForm adds"));8+9+class HTMLElement {}10+const ViewArtifact = vm.runInNewContext(`${classSource}\nViewArtifact`, {HTMLElement});11+12+const setup = ({complete = false, naturalWidth = 100} = {}) => {13+ const listeners = {};14+ const image = {complete, naturalWidth, hidden: false, addEventListener: (name, fn) => { listeners[name] = fn; }};15+ const details = {open: false};16+ const artifact = new ViewArtifact();17+ artifact.querySelector = selector => selector === "img" ? image : details;18+ artifact.connectedCallback();19+ return {image, details, fail: () => listeners.error()};20+};21+22+test("view artifact keeps a loaded image and source disclosure collapsed", () => {23+ const {image, details} = setup({complete: true});24+ assert.equal(image.hidden, false);25+ assert.equal(details.open, false);26+});27+28+test("view artifact opens source and hides a failed image", () => {29+ const {image, details, fail} = setup();30+ fail();31+ assert.equal(image.hidden, true);32+ assert.equal(details.open, true);33+});34+35+test("view artifact handles an already failed cached image", () => {36+ const {image, details} = setup({complete: true, naturalWidth: 0});37+ assert.equal(image.hidden, true);38+ assert.equal(details.open, true);39+});