Skip to content

feat: a description and a preview image per artifact (T2.1.3) - #43

Merged
kuyazee merged 3 commits into
mainfrom
task/seo-og
Aug 12, 2026
Merged

feat: a description and a preview image per artifact (T2.1.3)#43
kuyazee merged 3 commits into
mainfrom
task/seo-og

Conversation

@kuyazee

@kuyazee kuyazee commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

What this was

T2.1.3, the last big item in backlog 2 wave 1. "Per-artifact SEO/OG metadata. Optional
title/description/og-image fields on meta.json, injected into HTML/md shells; og-image can be
another artifact URL. Done when: settable via API/dashboard/MCP, renders in head, smoke case."

title already existed, so what this adds is description and ogImage.

What changed

The fields. description is one line, max 300 characters, with runs of whitespace collapsed.
ogImage is an absolute http(s) URL, no username or password, capped at 2048 characters measured
on the normalized href. Anything else is a 400 rather than a silent drop. Both live in
lib/social.js with the parsing and the tag rendering, for the reason lib/redirect.js sits
outside server.js: both halves are then testable without a running instance.

Where they render. The two heads the server builds per request: the viewer frame, which is what
a top-level visit to /a/<slug> gets while frames are on, and the markdown render. Seven tags,
conditional on what is set, ending in twitter:card as summary or summary_large_image. og:url
is the permanent /a/<slug> link, never a ?k= capability link: an unfurl outlives the message it
appeared in, and a token expires and can be revoked.

Nothing is spliced into stored bytes. An html artifact is served as-is, which is what
docs/formats.md has always promised, and a jsx page is baked at publish time, so writing tags
into either means editing an author's document and re-editing it on every later metadata change.
Two consequences are now written into the docs: an html, jsx or zip artifact carries the preview
only while it is framed, and a redirect stores the fields and renders nothing, the same way it
stores a frame value it never uses. Reasoning is in AGENT-DECISIONS.md, 2026-08-11.

Where they are settable. POST / PUT / PATCH, the zip endpoint's query string, the
publish_artifact and update_artifact MCP tools, and two row-menu items in the dashboard
("Description…" and "Preview image…"). PUT keeps both when they are omitted, the way it keeps
tags and project; PATCH with "" clears one. Both ride GET /api/artifacts.

One fix outside the fields, because the review found it here. The shells were filled by chaining
.replace('{{A}}', a).replace('{{B}}', b), so each value became part of the text the next call
searched. A description of {{CONTENT}} therefore became the target of the content substitution:
the whole rendered markdown body landed unescaped inside a quoted <meta> attribute, closed it, and
the real content slot shipped to the reader as the literal string {{CONTENT}}. lib/shells.js
does one pass with a lookup table instead. That also closes the older shape of the same bug, where a
title of {{SOURCE}} could eat a jsx artifact's source slot.

Screenshot

None this run. Publishing it was blocked: the sandbox classifier refused both the script that would
have posted the page and the sips call that would have shrunk the image enough to inline. The
three shots are on disk in the session scratchpad (preview-row-menu.png,
preview-image-refusal.png, preview-menu-390.png). What they show is written out under Tests.

Tests

npm test 49 to 65. bash .github/workflows/smoke.sh 154 to 176 assertions. Both green on the
local backend.

Every new positive assertion was checked against something that fails:

  • Deleting {{SOCIAL}} from shells/md.html turns the md assertion red; deleting it from
    shells/frame.html turns the frame assertions red. Each was run separately, so neither result is
    the other's shadow.
  • The placeholder case is red on the pre-fix build: the FAIL names the attribute that swallowed the
    body.
  • The escaping test asserts each tag is still one whole tag with two attributes, so a value that
    breaks out fails rather than merely looking different.
  • The description cap has a case that separates the two possible rules: 597 characters in, 299 after
    the whitespace collapses, which a build that measured the input would refuse.

Coverage the review asked for and this now has: a zip site's framed head (which also pins the
trailing slash in og:url), the zip endpoint's two query params including a refused one, og:title
carrying a real title rather than the slug fallback, X-Robots-Tag still on a page that has a
preview, and a private artifact reached through its capability link rendering the bare canonical
og:url with no token in it.

Browser pass at 1200px and 390px, console clean: both menu items marked with the existing rose check
when a value is stored and unmarked when not, the description input capped at 300 in the browser, the
whitespace collapse matching what the server stores when the dialog is reopened, a scheme-less image
URL reopening the box with the typed value and the reason above it, and the corrected URL saving and
reaching the served head.

Review

Four lenses on a 190-line diff, all four because the change renders into /a/:slug, which answers
without a key. Findings verified before acting: the placeholder bug was reproduced by hand before it
was fixed.

Fixed here:

  • A stored value could hijack a later shell placeholder (adversarial, security, QA, all three
    independently). Reproduced, then fixed with a single-pass fill plus five unit tests, including the
    $& case a function replacement was already guarding.
  • A rename plus a refused preview field left the artifact half-moved. patchArtifact moved
    storage before it validated the rest of the patch, so a 400 came back with the move already
    done: the row's link dead, the live URL in no row. The two new fields now parse before the move.
    The same shape reachable through the older fields is filed, not fixed.
  • docs/formats.md claimed ?raw=1 returns what was uploaded. True for html, jsx and zip; an
    md artifact renders through the same shell either way, so its ?raw=1 carries the tags too.
    Corrected, and the sentence now points at /a/:slug/source as the endpoint that always returns
    the uploaded bytes.
  • A refused image URL lost the typed URL and named the API's field. It said
    ogImage must be an absolute http:// or https:// URL in a toast after the dialog had closed. It
    now validates in the browser first and reopens the box with what was typed, the way "Target…"
    does, with wording about a chat app fetching the image rather than a visitor following a hop.
  • A too-long description lost all 301 characters. The input caps at 300 in the browser now, the
    way the tag editor caps a tag at 32.
  • Nothing showed that a preview was set, and both dialogs were silent about needing the frame.
    Each item now carries the existing "on" check when a value is stored, and each dialog says the
    preview shows while the frame is on, except on an md row where it does not depend on the frame.
  • A redirect could hold values the dashboard would not show. The two items were hidden on every
    redirect row, including one that already had a description set through the API. They now appear
    when a value is stored, so it can be seen and cleared.
  • The dashboard dropped whitespace differently from the server. a b stored as a b but
    redisplayed as typed. The row now collapses it the same way.
  • keepOrDrop was untestable and unreachable. It moved into lib/social.js as
    dropIfRefused with a test, since both parsers are stable on their own output and the branch only
    fires for meta an older build wrote.

Filed rather than fixed, as T2.1.13 to T2.1.16 in backlog 2:

  • Two concurrent PATCHes to one slug can corrupt meta.json and leave an artifact that answers
    404 on its own DELETE, reproduced 4 runs in 10. The cause is a bare fs.writeFile per object plus
    a full-record rewrite, both older than this change, but the dashboard now offers two more
    one-field PATCHes per row, so it is easier to hit. The fix is atomic writes across five backends
    plus merge-on-write, which is its own item (T2.1.13).
  • The rename-then-reject shape in patchArtifact for every field except the two this change
    touched (T2.1.14).
  • An unframed html or jsx artifact carries no preview at all (T2.1.15). Documented here; fixing
    it means either editing stored bytes or adding a string operation to the hot path for the most
    common type, which is a product call.
  • The CLI cannot set either field (T2.1.16). The item asked for API, dashboard and MCP, and
    docs/cli.md claims nothing, so this is a gap rather than a wrong doc.

Recorded and not acted on:

  • An ogImage may point at a loopback or link-local host. The server never fetches it, so there
    is no request forgery here; the fetch belongs to whichever chat app renders the card, which is the
    same reasoning already published for redirect targets.
  • A description can carry bidi control characters and display as a reversed look-alike URL in a
    preview card. Stripping them is a decision about what an operator may write, not a defect.
  • The dashboard has no CI coverage. Nothing in CI executes dashboard JS, a limit documented in
    the header of dashboard-check.mjs. Verified in a browser instead.

Merged main in, twice

First, PR #42 (0f9377c). One conflict, in docs/mcp.md: both sides added a paragraph directly
after the type line. #42's paragraph enumerates what list_artifacts returns, and it now names
description and ogImage alongside target. The served tool description in server.js gained the
same two, so the assertion #42 added, that the description names what the response actually carries,
still holds rather than passing on a technicality. smoke.sh and server.js merged on their own:
#42's MCP block and this branch's link-preview block do not touch the same lines, and the slug sets
do not collide (ci-redir-mcp against ci-preview*).

Then ten more commits (00747e8), including 405b024, which is the write chaining this branch's
own review filed as T2.1.13. That one landed while this PR sat open, so the concurrency hole the
review reported is now closed on main rather than still open behind this feature.

One conflict, in server.js, and it is the interesting one: saveArtifact and saveZipArtifact each
split into a slug-settling outer half that takes the write chain and a storing inner half. Main's
structure kept, with description and ogImage moved into storeArtifact and storeZipArtifact,
where the parsing and the meta writes already sat. Checked by hand afterwards that the
parse-before-rename ordering this branch added survived the refactor: it is still above the
storage.move inside applyPatch, so a patch carrying a new slug and a refused preview value still
refuses before anything moves.

After both merges: npm test 80, bash .github/workflows/smoke.sh 186 assertions, both green, with
every link-preview case and every case from the ten new commits present in the same run.

Two optional fields, description (one line, 300 chars) and ogImage (an
absolute http(s) URL), stored in meta.json and rendered as og: tags into the
two heads the server builds per request: the viewer frame and the markdown
page. Nothing is spliced into an html or jsx artifact's stored bytes, which
is what docs/formats.md promises for those types.

Settable on POST/PUT/PATCH, on the zip endpoint's query string, from two new
row-menu items in the dashboard, and as arguments on the publish_artifact and
update_artifact MCP tools. Both ride GET /api/artifacts. PUT keeps them when
omitted; PATCH with an empty string clears one.

Also fixes what the review turned up: the shells were filled by chaining
.replace(), so a stored value carrying the literal text of a later
placeholder became the target of that substitution. A description of
{{CONTENT}} put the whole rendered markdown body, unescaped, inside a meta
attribute and left the real content slot in the page as literal text. One
pass with a lookup table (lib/shells.js) closes it, including the older case
where a title could do the same to a jsx source.

Preview fields are also parsed before a rename moves storage, so a patch
carrying both a new slug and a refused value no longer half-applies.
PR #42 landed the list_artifacts field enumeration this branch also writes to.
One conflict, in docs/mcp.md, where both sides added a paragraph after the
type line. Both kept, and #42's field list gained description and ogImage;
the served tool description in server.js gained them too, so the assertion
#42 added (the description names what the response carries) still holds.

smoke.sh and server.js merged on their own: #42's MCP block and this branch's
link-preview block do not overlap, and neither do the two slug sets.

After the merge: npm test 65, smoke 176 assertions, both green.
Ten commits landed on main, including 405b024, which is the write chaining
this branch's review filed as T2.1.13: every meta write now runs through
withMetaChain, and saveArtifact and saveZipArtifact each split into a
slug-settling outer half and a storing inner half.

One conflict, in server.js, on both of those signatures. Main's structure
kept; description and ogImage moved into the inner functions, storeArtifact
and storeZipArtifact, where the parsing and the meta writes already sat. The
parse-before-rename ordering this branch added survived the refactor: it is
still above the storage.move inside applyPatch.

After the merge: npm test 80, smoke 186 assertions, both green.
@kuyazee
kuyazee merged commit f62b916 into main Aug 12, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant