tinyrelaycommit

Commit 835dc162e3de

browse files at this commit | history

1diff --git a/docs/view-definitions.md b/docs/view-definitions.md
2new file mode 100644
3index 0000000..fba695a
4--- /dev/null
5+++ b/docs/view-definitions.md
6@@ -0,0 +1,150 @@
7+# Reusable view definitions
8+
9+Status: Proposal. These definitions are not accepted by the current management API. [Custom views](views.md) describes the block transforms available today.
10+
11+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.
12+
13+## Three modes
14+
15+| Mode | Input and result | Examples |
16+| --- | --- | --- |
17+| `list` | A bounded event query becomes an ordered collection. | Articles by an author, open repository issues, a room feed. |
18+| `aggregate` | A bounded set of events becomes one derived result. | Counts by kind, a calendar with RSVP totals, a weekly activity report. |
19+| `render` | One event or an extracted part becomes a replacement or companion result. | Diagram blocks, a formatted event card, a rendered README. |
20+
21+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.
22+
23+## Shared fields
24+
25+| Field | Meaning |
26+| --- | --- |
27+| `version` | Definition format version. |
28+| `name` | Stable owner-selected name. |
29+| `mode` | `list`, `aggregate` or `render`. |
30+| `source` | A Nostr filter and a bounded selection policy. |
31+| `refresh` | Refresh on matching writes or on an interval. Manual refresh is always available. |
32+| `audience` | Who can read the view: `public` or `members`. |
33+| `presentation` | How the result appears: events, a table, structured data or an inline rendering. |
34+
35+`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.
36+
37+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.
38+
39+## Event list
40+
41+An article view needs a query and a presentation:
42+
43+```json
44+{
45+ "version": 1,
46+ "name": "articles",
47+ "mode": "list",
48+ "source": {
49+ "filter": { "kinds": [30023] },
50+ "limit": 100,
51+ "order": "newest"
52+ },
53+ "refresh": { "on": "write" },
54+ "audience": "public",
55+ "presentation": { "type": "events" }
56+}
57+```
58+
59+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.
60+
61+## Aggregate
62+
63+A report counts notes and articles written in the last seven days:
64+
65+```json
66+{
67+ "version": 1,
68+ "name": "weekly-activity",
69+ "mode": "aggregate",
70+ "source": {
71+ "filter": { "kinds": [1, 30023] },
72+ "window_seconds": 604800,
73+ "limit": 10000,
74+ "order": "newest"
75+ },
76+ "transform": {
77+ "type": "builtin",
78+ "name": "count",
79+ "group_by": ["kind"]
80+ },
81+ "refresh": { "every_seconds": 3600 },
82+ "audience": "members",
83+ "presentation": { "type": "table" }
84+}
85+```
86+
87+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.
88+
89+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.
90+
91+## Event rendering
92+
93+A diagram view selects events, extracts their fenced blocks and renders those blocks:
94+
95+```json
96+{
97+ "version": 1,
98+ "name": "diagrams",
99+ "mode": "render",
100+ "source": {
101+ "filter": { "kinds": [1, 30023, 30818] },
102+ "limit": 500,
103+ "order": "newest"
104+ },
105+ "input": {
106+ "type": "fenced-blocks",
107+ "from": "content",
108+ "languages": ["mermaid", "dot", "d2"]
109+ },
110+ "transform": {
111+ "type": "http",
112+ "url": "https://diagram.zip/transform/blocks",
113+ "connection": "diagram-renderer",
114+ "options": { "appearance": "auto-transparent" }
115+ },
116+ "output": {
117+ "types": ["image/svg+xml", "image/png"],
118+ "max_bytes": 1048576
119+ },
120+ "refresh": { "on": "write" },
121+ "audience": "public",
122+ "presentation": {
123+ "type": "inline",
124+ "target": "input",
125+ "fallback": "source"
126+ }
127+}
128+```
129+
130+Languages belong to the fenced-block extractor. Image types belong to this renderer's output contract. Neither is a requirement for every view.
131+
132+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.
133+
134+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.
135+
136+## Results and access
137+
138+Each mode has a distinct result identity:
139+
140+- A list identifies a query result and the referenced events.
141+- An aggregate identifies the definition revision and the selected source snapshot or window.
142+- 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.
143+
144+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.
145+
146+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.
147+
148+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.
149+
150+## Migration
151+
152+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.
153+
154+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.
155+
156+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.
157diff --git a/docs/views.md b/docs/views.md
158index 48b5e7e..1bf0e24 100644
159--- a/docs/views.md
160+++ b/docs/views.md
161@@ -1,22 +1,24 @@
162 # Custom views
163
164-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.
165+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.
166
167-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.
168+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.
169+
170+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.
171
172 ## Define a view
173
174 Call the `addcustomview` management method, or the `add_custom_view` MCP tool, as the owner:
175
176 ```json
177-{"method": "addcustomview", "params": [{"name": "diagrams", "kinds": [30818, 30023, 30618], "transform": "https://render.example/tiny", "languages": ["mermaid", "dot", "d2"], "trigger": "write", "audience": "public"}]}
178+{"method": "addcustomview", "params": [{"name": "diagrams", "kinds": [30818, 30023, 30618], "transform": "https://render.example/transform/blocks", "languages": ["mermaid", "dot", "d2"], "trigger": "write", "audience": "public"}]}
179 ```
180
181 | Field | Meaning |
182 | --- | --- |
183 | `name` | 1 to 32 lowercase letters, digits or hyphens. Names the view and its artifact paths. |
184 | `kinds` | 1 to 16 event kinds to watch. Kind 30618 sends the repository README at its head. |
185-| `transform` | An https URL on a public host. |
186+| `transform` | An HTTPS URL on a public host. |
187 | `languages` | 1 to 32 fenced block languages, lowercase. Only these blocks are sent. |
188 | `trigger` | `write` sends each matching event as it arrives, `hourly` sends the newest ones every hour. Defaults to `write`. |
189 | `audience` | `public` publishes the artifacts, `members` keeps them to members. Defaults to `public`. |
190@@ -37,19 +39,23 @@ A view posts one request for each source event, carrying only the blocks:
191
192 | Header | Value |
193 | --- | --- |
194-| `X-Tiny-View` | The view name. |
195-| `X-Tiny-Relay` | The relay's public URL. |
196-| `X-Tiny-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the body under the view's secret. |
197+| `X-Transform-View` | The view name. |
198+| `X-Transform-Relay` | The relay's public URL. |
199+| `X-Transform-Signature` | `sha256=` followed by the hex HMAC-SHA256 of the body under the view's secret. |
200
201 Verify the signature before rendering anything. The relay waits 30 seconds for an answer.
202
203+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.
204+
205 ## What the transform answers
206
207 ```json
208-{"artifacts": [{"block": 2, "type": "svg", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]}
209+{"artifacts": [{"block": 2, "type": "image/svg+xml", "body": "<svg ...>", "engine": "mermaid"}], "errors": [{"block": 5, "error": "unknown shape on line 3"}]}
210 ```
211
212-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.
213+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.
214+
215+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.
216
217 ## Artifacts
218
219@@ -57,7 +63,7 @@ An artifact is a relay-signed kind 30078 record addressed `bind.ws/view/<name>/<
220
221 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.
222
223-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.
224+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.
225
226 ## Failures
227
228@@ -73,5 +79,6 @@ A response outside 2xx, a timeout or a connection failure counts as a failure. T
229 | `pausecustomview <name>` | Stops transform requests. The artifacts stay. |
230 | `resumecustomview <name>` | Resumes the view and clears the failure count. |
231 | `runcustomview <name>` | Queues a backfill over the newest 500 events of the view's kinds. Blocks that already have an artifact are reused. |
232+| `runcustomview {"name": "diagrams", "refresh": true}` | Rebuilds artifacts for the newest 500 matching events. Existing artifacts stay available until a valid replacement arrives. |
233
234-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).
235+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).
236diff --git a/internal/daemon/custom_views.go b/internal/daemon/custom_views.go
237index 6ecf63b..2d3e248 100644
238--- a/internal/daemon/custom_views.go
239+++ b/internal/daemon/custom_views.go
240@@ -337,7 +337,8 @@ func (t *Tenant) changeCustomView(ctx context.Context, actor, method string, par
241 if !view.Enabled {
242 return nil, errors.New("invalid: resume the view before running it")
243 }
244- queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10))
245+ refresh := customViewRefreshParam(params)
246+ queued, err := t.queueCustomViewBackfill(ctx, view, 0, strconv.FormatInt(time.Now().UnixNano(), 10), refresh)
247 if err != nil {
248 return nil, err
249 }
250@@ -403,6 +404,19 @@ func customViewNameParam(params []json.RawMessage) string {
251 return strings.TrimSpace(options.Name)
252 }
253
254+func customViewRefreshParam(params []json.RawMessage) bool {
255+ if len(params) == 0 {
256+ return false
257+ }
258+ var options struct {
259+ Refresh bool `json:"refresh"`
260+ }
261+ if err := json.Unmarshal(params[0], &options); err != nil {
262+ return false
263+ }
264+ return options.Refresh
265+}
266+
267 func (t *Tenant) customViewByName(ctx context.Context, name string) (customView, error) {
268 rows, err := t.customViewRows(ctx, name)
269 if err != nil {
270@@ -518,7 +532,7 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
271 if e.Kind != event.KIND_REPO_STATE && len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {
272 continue
273 }
274- intents = append(intents, t.viewIntent(view, e, ""))
275+ intents = append(intents, t.viewIntent(view, e, "", false))
276 }
277 if len(intents) == 0 {
278 return
279@@ -536,8 +550,9 @@ func (t *Tenant) queueCustomViews(ctx context.Context, e event.Event) {
280 // viewIntent is the transform intent for one source. The event travels in
281 // the payload; run makes a backfill or an hourly pass distinct from the
282 // write-time intent for the same event.
283-func (t *Tenant) viewIntent(view customView, e event.Event, run string) storage.Intent {
284- payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run})
285+func (t *Tenant) viewIntent(view customView, e event.Event, run string, refresh ...bool) storage.Intent {
286+ force := len(refresh) > 0 && refresh[0]
287+ payload, _ := json.Marshal(viewPayload{Event: e, Attempt: 1, Run: run, Refresh: force})
288 id := e.ID
289 if run != "" {
290 id += "@" + run
291@@ -556,7 +571,8 @@ func (t *Tenant) statePending(ctx context.Context, id string) bool {
292 // queueCustomViewBackfill queues the newest sources of a view's kinds, at
293 // most 500, written since the given time. Repository states are queued
294 // whether or not their README has blocks; the handler reads it.
295-func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string) (int, error) {
296+func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, since int64, run string, refresh ...bool) (int, error) {
297+ force := len(refresh) > 0 && refresh[0]
298 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})
299 if err != nil {
300 return 0, err
301@@ -573,7 +589,7 @@ func (t *Tenant) queueCustomViewBackfill(ctx context.Context, view customView, s
302 } else if len(views.Matching(views.Blocks(e.Content), view.Languages)) == 0 {
303 continue
304 }
305- intents = append(intents, t.viewIntent(view, e, run))
306+ intents = append(intents, t.viewIntent(view, e, run, force))
307 }
308 if len(intents) == 0 {
309 return 0, nil
310@@ -599,7 +615,7 @@ func (t *Tenant) tickCustomViews(ctx context.Context, now int64) error {
311 if view.LastRunAt == 0 {
312 since = 0
313 }
314- queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10))
315+ queued, err := t.queueCustomViewBackfill(ctx, view, since, strconv.FormatInt(now/viewHourlyPeriod, 10), false)
316 if err != nil {
317 return err
318 }
319diff --git a/internal/daemon/custom_views_http.go b/internal/daemon/custom_views_http.go
320index 4ba3ebc..a0ae190 100644
321--- a/internal/daemon/custom_views_http.go
322+++ b/internal/daemon/custom_views_http.go
323@@ -1,10 +1,12 @@
324 package daemon
325
326-// GET /views/<name>/<hash>.<svg|png> serves a custom view artifact under a
327+// GET /views/<name>/<hash>[.<svg|png>] serves a custom view artifact under a
328 // sandbox policy. A members-only view answers only members; a public one
329 // follows the relay's read rule.
330
331 import (
332+ "crypto/sha256"
333+ "encoding/hex"
334 "net/http"
335 "strconv"
336 "strings"
337@@ -33,8 +35,8 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {
338 return
339 }
340 name := parts[0]
341- hash, extension, _ := strings.Cut(parts[1], ".")
342- if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (extension != "svg" && extension != "png") {
343+ hash, extension, hasExtension := strings.Cut(parts[1], ".")
344+ if !views.NamePattern.MatchString(name) || !views.HashPattern.MatchString(hash) || (hasExtension && extension != "svg" && extension != "png") {
345 http.NotFound(w, r)
346 outcome = "invalid"
347 return
348@@ -56,7 +58,7 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {
349 return
350 }
351 wantType := map[string]string{"svg": "image/svg+xml", "png": "image/png"}[extension]
352- if record.Type != wantType {
353+ if extension != "" && record.Type != wantType {
354 http.NotFound(w, r)
355 outcome = "invalid"
356 return
357@@ -91,12 +93,19 @@ func (t *Tenant) viewArtifactHTTP(w http.ResponseWriter, r *http.Request) {
358 w.Header().Set("Content-Length", strconv.Itoa(len(body)))
359 w.Header().Set("Content-Security-Policy", "sandbox; default-src 'none'; style-src 'unsafe-inline'")
360 w.Header().Set("X-Content-Type-Options", "nosniff")
361+ etagBytes := sha256.Sum256(body)
362+ etag := `"` + hex.EncodeToString(etagBytes[:]) + `"`
363+ w.Header().Set("ETag", etag)
364 if record.Audience == "members" {
365- w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
366+ w.Header().Set("Cache-Control", "private, no-cache")
367 } else {
368- w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
369+ w.Header().Set("Cache-Control", "public, no-cache")
370 }
371 outcome = "ok"
372+ if r.Header.Get("If-None-Match") == etag {
373+ w.WriteHeader(http.StatusNotModified)
374+ return
375+ }
376 if r.Method == http.MethodHead {
377 w.WriteHeader(http.StatusOK)
378 return
379diff --git a/internal/daemon/custom_views_media_test.go b/internal/daemon/custom_views_media_test.go
380new file mode 100644
381index 0000000..69a2677
382--- /dev/null
383+++ b/internal/daemon/custom_views_media_test.go
384@@ -0,0 +1,45 @@
385+package daemon
386+
387+import (
388+ "net/http"
389+ "net/http/httptest"
390+ "strings"
391+ "testing"
392+ "time"
393+
394+ "github.com/FelineStateMachine/tinyrelay/internal/views"
395+)
396+
397+func TestCustomViewMediaPathServesStoredType(t *testing.T) {
398+ for _, mediaType := range []string{"image/svg+xml", "image/png"} {
399+ t.Run(mediaType, func(t *testing.T) {
400+ tenant, server, _ := viewTenant(t, []int{1}, nil)
401+ artifact := svgArtifact(0)
402+ artifact["type"] = mediaType
403+ if mediaType == "image/png" {
404+ artifact["body"] = "iVBORw0KGgoAAAANSUhEUg=="
405+ }
406+ server.answer(http.StatusOK, artifact)
407+ e := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\ngraph TD; a-->b;\n```")
408+ if err := publishAs(t, tenant, e); err != nil {
409+ t.Fatal(err)
410+ }
411+ runPendingView(t, tenant, "diagrams", e.ID)
412+ _, image, ok := strings.Cut(views.Figure("diagrams", "mermaid", "graph TD; a-->b;"), `<img src="`)
413+ if !ok {
414+ t.Fatal("custom view has no image")
415+ }
416+ path, _, _ := strings.Cut(image, `"`)
417+ res := getArtifact(t, tenant, path, "")
418+ if res.Code != http.StatusOK || res.Header().Get("Content-Type") != mediaType {
419+ t.Fatalf("media response: %d %s", res.Code, res.Body.String())
420+ }
421+ request := httptest.NewRequest(http.MethodHead, "http://127.0.0.1:8080"+path, nil)
422+ head := httptest.NewRecorder()
423+ tenant.ServeHTTP(head, request)
424+ if head.Code != http.StatusOK || head.Body.Len() != 0 || head.Header().Get("ETag") != res.Header().Get("ETag") {
425+ t.Fatalf("media HEAD: %d %s", head.Code, head.Body.String())
426+ }
427+ })
428+ }
429+}
430diff --git a/internal/daemon/custom_views_test.go b/internal/daemon/custom_views_test.go
431index 57d155e..32fa8ed 100644
432--- a/internal/daemon/custom_views_test.go
433+++ b/internal/daemon/custom_views_test.go
434@@ -367,6 +367,11 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {
435 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)) {
436 t.Fatalf("headers = %v", headers)
437 }
438+ for _, field := range []string{"View", "Relay", "Signature"} {
439+ if headers.Get("X-Transform-"+field) != headers.Get("X-Tiny-"+field) {
440+ t.Fatalf("canonical transform header %s differs from its legacy value", field)
441+ }
442+ }
443 var request map[string]any
444 if err := json.Unmarshal(body, &request); err != nil {
445 t.Fatal(err)
446@@ -414,14 +419,21 @@ func TestCustomViewTransformSendsBlocksAndKeepsSignedArtifacts(t *testing.T) {
447
448 // The route serves each artifact under a sandbox, by its own type.
449 res := getArtifact(t, tenant, "/views/diagrams/"+svgHash+".svg", "")
450- 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" {
451+ 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") == "" {
452 t.Fatalf("svg route: %d %v %s", res.Code, res.Header(), res.Body.String())
453 }
454+ conditional := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:8080/views/diagrams/"+svgHash+".svg", nil)
455+ conditional.Header.Set("If-None-Match", res.Header().Get("ETag"))
456+ conditionalResponse := httptest.NewRecorder()
457+ tenant.ServeHTTP(conditionalResponse, conditional)
458+ if conditionalResponse.Code != http.StatusNotModified {
459+ t.Fatalf("conditional artifact: %d %v", conditionalResponse.Code, conditionalResponse.Header())
460+ }
461 res = getArtifact(t, tenant, "/views/diagrams/"+pngHash+".png", "")
462 if res.Code != http.StatusOK || res.Body.String() != testPNGRaw || res.Header().Get("Content-Type") != "image/png" {
463 t.Fatalf("png route: %d %v", res.Code, res.Header())
464 }
465- 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"} {
466+ 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"} {
467 if res := getArtifact(t, tenant, path, ""); res.Code != http.StatusNotFound {
468 t.Fatalf("%s: %d", path, res.Code)
469 }
470@@ -562,6 +574,60 @@ func TestCustomViewReplacementReusesAndDropsBlocks(t *testing.T) {
471 }
472 }
473
474+func queueViewRebuild(t *testing.T, tenant *Tenant, source string) work.Intent {
475+ t.Helper()
476+ params := viewParams(map[string]any{"name": "diagrams", "refresh": true})
477+ if _, err := tenant.Execute(context.Background(), tenant.Policy().Owner, "runcustomview", params); err != nil {
478+ t.Fatal(err)
479+ }
480+ for _, intent := range pendingViewIntents(t, tenant, "diagrams", source) {
481+ var payload viewPayload
482+ if err := json.Unmarshal([]byte(intent.Payload), &payload); err != nil {
483+ t.Fatal(err)
484+ }
485+ if payload.Attempt == 1 {
486+ if !payload.Refresh {
487+ t.Fatal("queued rebuild lost refresh flag")
488+ }
489+ return intent
490+ }
491+ }
492+ t.Fatal("no rebuild was queued")
493+ return work.Intent{}
494+}
495+
496+func TestCustomViewRebuildReplacesOnlyAfterSuccess(t *testing.T) {
497+ tenant, server, _ := viewTenant(t, []int{1}, nil)
498+ note := signedEvent(t, testOwnerSecret, 1, time.Now().Unix(), nil, "```mermaid\nA\n```")
499+ if err := publishAs(t, tenant, note); err != nil {
500+ t.Fatal(err)
501+ }
502+ server.answer(http.StatusOK, svgArtifact(0))
503+ runPendingView(t, tenant, "diagrams", note.ID)
504+ path := views.Path("diagrams", views.Hash("mermaid", "A"), "")
505+ before := getArtifact(t, tenant, path, "")
506+ if before.Code != http.StatusOK {
507+ t.Fatalf("initial artifact: %d", before.Code)
508+ }
509+ server.answer(http.StatusInternalServerError)
510+ runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID))
511+ afterFailure := getArtifact(t, tenant, path, "")
512+ if afterFailure.Body.String() != before.Body.String() || afterFailure.Header().Get("ETag") != before.Header().Get("ETag") {
513+ t.Fatal("failed rebuild changed the stored artifact")
514+ }
515+ replacement := svgArtifact(0)
516+ replacement["body"] = strings.Replace(testSVG, "<rect", `<rect fill="#334155"`, 1)
517+ server.answer(http.StatusOK, replacement)
518+ runViewIntent(t, tenant, queueViewRebuild(t, tenant, note.ID))
519+ after := getArtifact(t, tenant, path, "")
520+ if after.Body.String() != replacement["body"] || after.Header().Get("ETag") == before.Header().Get("ETag") {
521+ t.Fatal("successful rebuild did not replace and revalidate the artifact")
522+ }
523+ if server.count() != 3 {
524+ t.Fatalf("transform requests = %d", server.count())
525+ }
526+}
527+
528 func TestCustomViewTransformRetriesThenPauses(t *testing.T) {
529 tenant, server, _ := viewTenant(t, []int{1}, nil)
530 ctx := context.Background()
531@@ -729,7 +795,7 @@ func TestCustomViewMembersAudienceStaysOffTheStore(t *testing.T) {
532 }
533 for _, secret := range []string{testMemberSecret, testOwnerSecret} {
534 res := getArtifact(t, tenant, path, secret)
535- if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, max-age=31536000, immutable" {
536+ if res.Code != http.StatusOK || res.Body.String() != testSVG || res.Header().Get("Cache-Control") != "private, no-cache" || res.Header().Get("ETag") == "" {
537 t.Fatalf("member: %d %v", res.Code, res.Header())
538 }
539 }
540diff --git a/internal/daemon/custom_views_transform.go b/internal/daemon/custom_views_transform.go
541index 6767a26..2047f05 100644
542--- a/internal/daemon/custom_views_transform.go
543+++ b/internal/daemon/custom_views_transform.go
544@@ -40,6 +40,7 @@ type viewPayload struct {
545 Event event.Event `json:"event"`
546 Attempt int `json:"attempt"`
547 Run string `json:"run,omitempty"`
548+ Refresh bool `json:"refresh,omitempty"`
549 }
550
551 // viewRequest is the body posted to a transform: only the blocks, with the
552@@ -227,12 +228,18 @@ func (t *Tenant) handleViewTransform(ctx context.Context, intent work.Intent) (e
553 blocks = views.Matching(blocks, view.Languages)
554 source := viewSourceKey(payload.Event)
555 expires := event.Expiration(payload.Event)
556- // Attach the blocks that already have an artifact and keep the rest.
557+ // A rebuild deliberately bypasses existing artifacts. The replacement is
558+ // written only after the transform succeeds, so a failed rebuild preserves
559+ // the previous artifact.
560 var pending []views.Block
561 hashes := make([]string, 0, len(blocks))
562 for _, block := range blocks {
563 hash := views.Hash(block.Lang, block.Source)
564 hashes = append(hashes, hash)
565+ if payload.Refresh {
566+ pending = append(pending, block)
567+ continue
568+ }
569 exists, err := t.attachArtifact(ctx, view, hash, source, block.Index, expires)
570 if err != nil {
571 return err
572@@ -350,7 +357,11 @@ func (t *Tenant) postViewTransform(ctx context.Context, view customView, e event
573 request.Header.Set("Accept", "application/json")
574 request.Header.Set("X-Tiny-View", view.Name)
575 request.Header.Set("X-Tiny-Relay", t.publicURL)
576- request.Header.Set("X-Tiny-Signature", callbackSignature(view.secret, body))
577+ signature := callbackSignature(view.secret, body)
578+ request.Header.Set("X-Tiny-Signature", signature)
579+ request.Header.Set("X-Transform-View", view.Name)
580+ request.Header.Set("X-Transform-Relay", t.publicURL)
581+ request.Header.Set("X-Transform-Signature", signature)
582 request.Header.Set("User-Agent", "tinyrelay")
583 response, err := t.viewHTTPClient().Do(request)
584 if err != nil {
585diff --git a/internal/daemon/mcp_tools.go b/internal/daemon/mcp_tools.go
586index 19a9244..1ee0130 100644
587--- a/internal/daemon/mcp_tools.go
588+++ b/internal/daemon/mcp_tools.go
589@@ -167,7 +167,7 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
590 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) {
591 return t.mcpManage(ctx, call, "addcallback", call.Arguments)
592 })
593- 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) {
594+ 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) {
595 return t.mcpManage(ctx, call, "addcustomview", call.Arguments)
596 })
597 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) {
598@@ -179,8 +179,8 @@ func (t *Tenant) mcpTools() (*mcp.Registry, error) {
599 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) {
600 return t.mcpManage(ctx, call, "resumecustomview", call.String("name"))
601 })
602- 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) {
603- return t.mcpManage(ctx, call, "runcustomview", call.String("name"))
604+ 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) {
605+ return t.mcpManage(ctx, call, "runcustomview", call.Arguments)
606 })
607 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) {
608 return t.mcpManage(ctx, call, "removecallback", call.String("id"))
609diff --git a/internal/views/views.go b/internal/views/views.go
610index 273a26d..f77ca8e 100644
611--- a/internal/views/views.go
612+++ b/internal/views/views.go
613@@ -92,19 +92,27 @@ var HashPattern = regexp.MustCompile(`^[0-9a-f]{64}$`)
614
615 // Path is the address an artifact is served at.
616 func Path(view, hash, extension string) string {
617- return "/views/" + view + "/" + hash + "." + extension
618+ path := "/views/" + view + "/" + hash
619+ if extension != "" {
620+ path += "." + extension
621+ }
622+ return path
623 }
624
625-// Figure is the markup for a block a custom view renders: the artifact as
626-// an object with the escaped code block inside it, so a missing or failed
627-// artifact shows the code without any script.
628+// Figure is the markup for a block a custom view renders. The image remains
629+// transparent and follows the page color scheme, while the native details
630+// disclosure keeps the source available without requiring script.
631 func Figure(view, lang, source string) string {
632 var b strings.Builder
633- b.WriteString(`<figure data-view="` + html.EscapeString(view) + `"><object data="` + Path(view, Hash(lang, source), "svg") + `" type="image/svg+xml"><pre><code`)
634+ alt := "Rendered view"
635+ if lang != "" {
636+ alt = "Rendered " + lang + " block"
637+ }
638+ 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`)
639 if lang != "" {
640 b.WriteString(` data-lang="` + html.EscapeString(lang) + `"`)
641 }
642- b.WriteString(">" + html.EscapeString(source) + "</code></pre></object></figure>\n")
643+ b.WriteString(">" + html.EscapeString(source) + "</code></pre></details></view-artifact></figure>\n")
644 return b.String()
645 }
646
647diff --git a/internal/views/views_test.go b/internal/views/views_test.go
648index 3b730f3..d06b152 100644
649--- a/internal/views/views_test.go
650+++ b/internal/views/views_test.go
651@@ -64,7 +64,7 @@ func TestHashCoversLanguageAndSource(t *testing.T) {
652 func TestFigureEmbedsTheArtifactWithTheCodeAsFallback(t *testing.T) {
653 got := Figure("diagrams", "mermaid", "graph TD;\n A-->B & <C>;")
654 hash := Hash("mermaid", "graph TD;\n A-->B & <C>;")
655- want := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD;` + "\n" + ` A--&gt;B &amp; &lt;C&gt;;</code></pre></object></figure>` + "\n"
656+ 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--&gt;B &amp; &lt;C&gt;;</code></pre></details></view-artifact></figure>` + "\n"
657 if got != want {
658 t.Fatalf("figure =\n%s\nwant\n%s", got, want)
659 }
660diff --git a/internal/webui/components.js b/internal/webui/components.js
661index f829c66..1c50bb5 100644
662--- a/internal/webui/components.js
663+++ b/internal/webui/components.js
664@@ -20,6 +20,9 @@
665 // Renders JSON as tables, lists and definition lists, keeping the raw
666 // text in a <details>. Server pages seed it with a <pre>; scripts set .value.
667 // Tables sit inside a <scroll-box> so wide rows scroll sideways.
668+// <view-artifact>
669+// Shows a custom view image and keeps its fenced source in a native
670+// disclosure when the image cannot be loaded.
671 // <nostr-key hex="…">
672 // Shortened key with the full hex as title; click copies it.
673 // <file-mirror action="/mirror">
674@@ -181,6 +184,16 @@
675 }
676 }
677
678+ class ViewArtifact extends HTMLElement {
679+ connectedCallback() {
680+ const image = this.querySelector("img"), source = this.querySelector("details");
681+ if (!image || !source) return;
682+ const failed = () => { image.hidden = true; source.open = true; };
683+ image.addEventListener("error", failed, {once: true});
684+ if (image.complete && image.naturalWidth === 0) failed();
685+ }
686+ }
687+
688 // ViewForm adds a custom view: it composes the addcustomview object from
689 // its fields and shows the returned secret once, since the relay never
690 // shows it again.
691@@ -1715,6 +1728,7 @@
692 ensureModules();
693
694 customElements.define("rpc-form", RpcForm);
695+ customElements.define("view-artifact", ViewArtifact);
696 customElements.define("view-form", ViewForm);
697 customElements.define("connect-card", ConnectCard);
698 customElements.define("connect-list", ConnectList);
699diff --git a/internal/webui/custom_views_test.go b/internal/webui/custom_views_test.go
700index f6e5d73..d1de239 100644
701--- a/internal/webui/custom_views_test.go
702+++ b/internal/webui/custom_views_test.go
703@@ -46,7 +46,7 @@ func (b *viewsBackend) Query(ctx context.Context, method string, params []json.R
704 const mermaidSource = "graph TD;\n A-->B & <C>;"
705
706 func mermaidFigure(view string) string {
707- 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--&gt;B &amp; &lt;C&gt;;</code></pre></object></figure>`
708+ 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--&gt;B &amp; &lt;C&gt;;</code></pre></details></view-artifact></figure>`
709 }
710
711 func TestRenderersShowCustomViewArtifactsWithTheCodeAsFallback(t *testing.T) {
712@@ -100,9 +100,9 @@ func TestPagesRenderCustomViewFiguresFromTheBackendSummary(t *testing.T) {
713 t.Fatalf("repo home: %d %s", recorder.Code, body)
714 }
715 hash := views.Hash("mermaid", "graph TD; a-->b;")
716- figure := `<figure data-view="diagrams"><object data="/views/diagrams/` + hash + `.svg" type="image/svg+xml"><pre><code data-lang="mermaid">graph TD; a--&gt;b;</code></pre></object></figure>`
717+ 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--&gt;b;</code></pre></details></view-artifact></figure>`
718 wantAll(t, "repo home", body, figure, `href="/repo?owner=`)
719- if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<object data="/r/work/views/diagrams/`+hash+`.svg"`) {
720+ if prefixed := injectBase(figure, "/r/work"); !strings.Contains(prefixed, `<img src="/r/work/views/diagrams/`+hash+`"`) {
721 t.Fatalf("tenant prefix missed the object address: %s", prefixed)
722 }
723 // A backend without the summary keeps code blocks as code.
724diff --git a/internal/webui/manage.html b/internal/webui/manage.html
725index a1f8b10..c2dd68c 100644
726--- a/internal/webui/manage.html
727+++ b/internal/webui/manage.html
728@@ -246,8 +246,8 @@
729 <h1>Views</h1>
730 <rpc-form method="listviews"><button>List views</button></rpc-form>
731 <rpc-form method="publishview"><label>View name <input name="param" required></label><button>Publish view</button></rpc-form>
732-{{if .Owner}}<h3>Custom views</h3>
733-<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>
734+{{if .Owner}}<h3>Block transforms</h3>
735+<p>Block transforms render fenced code as images. Choose the event kinds and languages to send to your rendering service.</p>
736 {{if .CustomViews}}<table id="custom-views">
737 <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>
738 <tbody>{{range .CustomViews}}{{$v := asMap .}}<tr id="view-{{str (index $v "name")}}">
739@@ -258,14 +258,14 @@
740 <td>{{str (index $v "audience")}}</td>
741 <td data-state="{{viewState $v}}">{{viewState $v}}{{with str (index $v "lastStatus")}} | {{.}}{{end}}</td>
742 <td>{{with index $v "lastRun"}}{{when .}}{{else}}never{{end}}</td>
743-<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>
744+<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>
745 </tr>{{end}}</tbody>
746 </table>{{else}}<p id="custom-views-empty">No custom views yet.</p>{{end}}
747 <view-form method="addcustomview">
748 <label>Name <input name="view-name" pattern="[a-z0-9-]{1,32}" placeholder="diagrams" required></label>
749 <label>Kinds <input name="view-kinds" placeholder="1, 30023, 30618" required></label>
750 <label>Languages <input name="view-languages" placeholder="mermaid, dot" required></label>
751-<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/tiny" required></label>
752+<label>Transform URL <input name="view-transform" type="url" placeholder="https://render.example/transform/blocks" required></label>
753 <label>Trigger <select name="view-trigger"><option value="write">write</option><option value="hourly">hourly</option></select></label>
754 <label>Audience <select name="view-audience"><option value="public">public</option><option value="members">members</option></select></label>
755 <label>Max artifact bytes <input name="view-max-bytes" type="number" min="1" max="4194304" placeholder="1048576"></label>
756diff --git a/internal/webui/style.css b/internal/webui/style.css
757index cb23ced..da26a8d 100644
758--- a/internal/webui/style.css
759+++ b/internal/webui/style.css
760@@ -4,6 +4,7 @@
761 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}
762 @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}}
763
764+html{color-scheme:light}html[data-theme=dark]{color-scheme:dark}@media(prefers-color-scheme:dark){html:not([data-theme=light]){color-scheme:dark}}
765 html,body{margin:0;padding:0;height:100%;overflow:hidden;overscroll-behavior:none}
766 body{font:14px/1.45 var(--sans);color:var(--ink);background:var(--paper)}
767 a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}
768@@ -161,8 +162,8 @@ view-form{display:block;border-bottom:1px solid var(--line);padding:10px 0;max-w
769 view-form label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}
770 view-form label input,view-form label select{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}
771 view-form [role=alert]{color:var(--alert);font:13px var(--sans);margin:8px 0 4px}view-form label[data-secret] input{min-width:320px}
772-/* figure[data-view]: an artifact a custom view rendered; the code inside the object shows when the artifact is missing. */
773-figure[data-view]{margin:12px 0}figure[data-view] object{display:block;max-width:100%}figure[data-view] pre{margin:0}
774+/* figure[data-view]: a transparent artifact with a native source disclosure. */
775+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}
776 /* agent-callback: one registered callback on an agent card, with its controls. */
777 agent-callback{display:block;margin:0 0 4px}agent-callback code{color:inherit}
778 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}
779diff --git a/package.json b/package.json
780index 35a2a6f..d7d951c 100644
781--- a/package.json
782+++ b/package.json
783@@ -15,7 +15,8 @@
784 "test:wiki": "node --test scripts/wiki-compose.test.mjs",
785 "test:rooms": "node --test scripts/rooms.test.mjs",
786 "test:profile": "node --test scripts/profile-form.test.mjs",
787- "test:page-link": "node --test scripts/page-link.test.mjs"
788+ "test:page-link": "node --test scripts/page-link.test.mjs",
789+ "test:view-artifact": "node --test scripts/view-artifact.test.mjs"
790 },
791 "devDependencies": {
792 "@noble/hashes": "2.4.0",
793diff --git a/scripts/view-artifact.test.mjs b/scripts/view-artifact.test.mjs
794new file mode 100644
795index 0000000..1c3dd6d
796--- /dev/null
797+++ b/scripts/view-artifact.test.mjs
798@@ -0,0 +1,39 @@
799+import test from "node:test";
800+import assert from "node:assert/strict";
801+import fs from "node:fs";
802+import vm from "node:vm";
803+
804+const source = fs.readFileSync("internal/webui/components.js", "utf8");
805+const classSource = source.slice(source.indexOf(" class ViewArtifact"), source.indexOf(" // ViewForm adds"));
806+
807+class HTMLElement {}
808+const ViewArtifact = vm.runInNewContext(`${classSource}\nViewArtifact`, {HTMLElement});
809+
810+const setup = ({complete = false, naturalWidth = 100} = {}) => {
811+ const listeners = {};
812+ const image = {complete, naturalWidth, hidden: false, addEventListener: (name, fn) => { listeners[name] = fn; }};
813+ const details = {open: false};
814+ const artifact = new ViewArtifact();
815+ artifact.querySelector = selector => selector === "img" ? image : details;
816+ artifact.connectedCallback();
817+ return {image, details, fail: () => listeners.error()};
818+};
819+
820+test("view artifact keeps a loaded image and source disclosure collapsed", () => {
821+ const {image, details} = setup({complete: true});
822+ assert.equal(image.hidden, false);
823+ assert.equal(details.open, false);
824+});
825+
826+test("view artifact opens source and hides a failed image", () => {
827+ const {image, details, fail} = setup();
828+ fail();
829+ assert.equal(image.hidden, true);
830+ assert.equal(details.open, true);
831+});
832+
833+test("view artifact handles an already failed cached image", () => {
834+ const {image, details} = setup({complete: true, naturalWidth: 0});
835+ assert.equal(image.hidden, true);
836+ assert.equal(details.open, true);
837+});

About

ownerc01503e8035f
visibilitypublic
head835dc162e3de

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs