From b0c481e680e370fa6f9fd8b73a6727e3f512f261 Mon Sep 17 00:00:00 2001 From: Graham Barber Date: Mon, 17 Aug 2026 21:18:40 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20adoption=20round-trip=20=E2=80=94=20unl?= =?UTF-8?q?ink=20and=20the=20unrenderable-document=20outlink?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field findings from the first real adoption (a Leaflet test doc): shadowing needs an undo that isn't deletion, and pub.leaflet content rendered as silent emptiness. changelog unlink removes exactly the releaseLink (idempotent, everything else untouched) so the shadowed original serves again. Views compose canonicalUrl (site + path, http(s) sites only) and a changelog offering neither html nor plaintext now says so and links home — the lens points at where the document lives rather than pretending it is empty. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PWXFcuaRLZycYVAF7senMK --- cmd/disttown/changelog.go | 67 +++++++++++++++++++ internal/appview/views.go | 6 ++ internal/gen/distdefs.go | 4 ++ internal/index/store.go | 13 ++++ lexicons/town/dist/defs.json | 10 +++ .../specs/changelog-authoring/spec.md | 9 +++ .../specs/changelog-integration/spec.md | 9 +++ .../specs/consumer-surfaces/spec.md | 9 +++ openspec/changes/changelog-adoption/tasks.md | 5 ++ packages/client/src/generated/lexicons.ts | 12 ++++ .../src/generated/types/town/dist/defs.ts | 4 ++ web/src/lib/components/ChangelogProse.svelte | 19 ++++-- .../lib/components/ChangelogStreamPage.svelte | 2 +- web/src/lib/components/ReleasePage.svelte | 6 +- web/src/routes/docs/cli/+page.md | 11 +++ 15 files changed, 180 insertions(+), 6 deletions(-) diff --git a/cmd/disttown/changelog.go b/cmd/disttown/changelog.go index ec16e21..da24de3 100644 --- a/cmd/disttown/changelog.go +++ b/cmd/disttown/changelog.go @@ -47,9 +47,76 @@ var cmdChangelog = &cli.Command{ }, Action: runChangelogLink, }, + { + Name: "unlink", + Usage: "detach a document from a release — removes the release link, changes nothing else", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{Name: "project", Usage: "project name", Required: true}, + &cli.StringFlag{Name: "doc", Usage: "the document to detach: an rkey or at-uri in your own repo", Required: true}, + }, + Action: runChangelogUnlink, + }, }, } +func runChangelogUnlink(c *cli.Context) error { + ctx := c.Context + if c.Args().Len() != 1 { + return fmt.Errorf("usage: disttown changelog unlink ") + } + version := c.Args().First() + project := canon.NormalizeName(c.String("project")) + if err := canon.ValidateName(project); err != nil { + return err + } + + s, err := atsession.Get(ctx) + if err != nil { + return err + } + docRKey, err := docRKeyArg(c.String("doc"), s.DID) + if err != nil { + return err + } + releases, err := listReleases(ctx, s) + if err != nil { + return err + } + rkey := releaseByVersion(releases, atURI(s.DID, canon.NSIDProject, project), version) + if rkey == "" { + return fmt.Errorf("no release of %s claims version %q", project, version) + } + target := atURI(s.DID, canon.NSIDRelease, rkey) + + out, err := atproto.RepoGetRecord(ctx, s.Client, "", nsidDocument, s.DID, docRKey) + if err != nil { + return fmt.Errorf("fetching document %s: %w", docRKey, err) + } + doc, ok := out.Value.Val.(*sitedoc.Document) + if !ok { + return fmt.Errorf("%s is not a site.standard.document this CLI understands", docRKey) + } + kept := doc.Links[:0] + for _, l := range doc.Links { + if l != nil && l.Target == target { + continue + } + kept = append(kept, l) + } + if len(kept) == len(doc.Links) { + fmt.Printf("not linked: %s does not describe %s@%s\n", atURI(s.DID, nsidDocument, docRKey), project, version) + return nil + } + doc.Links = kept + write := updateElem(nsidDocument, docRKey, doc) + if err := applyWrites(ctx, s, []*atproto.RepoApplyWrites_Input_Writes_Elem{write}); err != nil { + return scopeHint(err) + } + fmt.Printf("detached from %s@%s\n document: %s\n", project, version, atURI(s.DID, nsidDocument, docRKey)) + return nil +} + func runChangelogLink(c *cli.Context) error { ctx := c.Context if c.Args().Len() != 1 { diff --git a/internal/appview/views.go b/internal/appview/views.go index 89c6e22..9048604 100644 --- a/internal/appview/views.go +++ b/internal/appview/views.go @@ -87,6 +87,9 @@ func releaseView(ctx context.Context, store *index.Store, did, pds string, rel * } if cl != nil { cv := &gen.Defs_ChangelogView{Uri: cl.URI, Title: cl.Title, Tags: cl.Tags} + if cl.CanonicalURL != "" { + cv.CanonicalUrl = &cl.CanonicalURL + } if cl.TextContent != "" { cv.TextContent = &cl.TextContent } @@ -152,6 +155,9 @@ func changelogDocView(c *index.Changelog, refs map[string]releaseRef, scope stri Releases: rels, Tags: c.Tags, } + if c.CanonicalURL != "" { + v.CanonicalUrl = &c.CanonicalURL + } if c.TextContent != "" { v.TextContent = &c.TextContent } diff --git a/internal/gen/distdefs.go b/internal/gen/distdefs.go index 37cc18d..4af8e1e 100644 --- a/internal/gen/distdefs.go +++ b/internal/gen/distdefs.go @@ -154,6 +154,8 @@ type Defs_BlobStorage struct { // // A changelog document in a stream or permalink: the document plus every release it describes (a shared document may describe several). type Defs_ChangelogDocView struct { + // canonicalUrl: The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here. + CanonicalUrl *string `json:"canonicalUrl,omitempty" cborgen:"canonicalUrl,omitempty"` // html: Sanitized HTML rendered from markpub content, as in #changelogView. Html *string `json:"html,omitempty" cborgen:"html,omitempty"` PublishedAt string `json:"publishedAt" cborgen:"publishedAt"` @@ -180,6 +182,8 @@ type Defs_ChangelogReleaseRef struct { // // The authoritative release notes: a same-DID site.standard.document backlinking the release. Cross-DID documents never appear here. type Defs_ChangelogView struct { + // canonicalUrl: The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here. + CanonicalUrl *string `json:"canonicalUrl,omitempty" cborgen:"canonicalUrl,omitempty"` // html: HTML rendered from the document's markpub content, sanitized at the read plane (the single chokepoint for publisher prose). Absent for plaintext-only documents. Html *string `json:"html,omitempty" cborgen:"html,omitempty"` PublishedAt *string `json:"publishedAt,omitempty" cborgen:"publishedAt,omitempty"` diff --git a/internal/index/store.go b/internal/index/store.go index 6286f7b..5c54033 100644 --- a/internal/index/store.go +++ b/internal/index/store.go @@ -386,6 +386,8 @@ type standardDocument struct { Target string `json:"target"` } `json:"links"` Tags []string `json:"tags"` + Site string `json:"site"` + Path string `json:"path"` } // Changelog is an authoritative release-notes document. @@ -405,6 +407,10 @@ type Changelog struct { // Tags are the document's tags verbatim: publisher-authored // subject vocabulary, claims rather than verified facts. Tags []string + // CanonicalURL is the document's home (site + path), composed + // when the site is an http(s) url — the way in when the content + // format cannot be rendered here. + CanonicalURL string } func (d *standardDocument) changelog(did, rkey string) *Changelog { @@ -417,6 +423,13 @@ func (d *standardDocument) changelog(did, rkey string) *Changelog { UpdatedAt: d.UpdatedAt, Tags: d.Tags, } + if d.Path != "" && (strings.HasPrefix(d.Site, "http://") || strings.HasPrefix(d.Site, "https://")) { + path := d.Path + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + c.CanonicalURL = strings.TrimSuffix(d.Site, "/") + path + } if d.Content != nil && d.Content.Type == "at.markpub.markdown" && d.Content.Text != nil { c.Markdown = d.Content.Text.Markdown } diff --git a/lexicons/town/dist/defs.json b/lexicons/town/dist/defs.json index f7c16a4..f68b769 100644 --- a/lexicons/town/dist/defs.json +++ b/lexicons/town/dist/defs.json @@ -178,6 +178,11 @@ "type": "array", "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 }, "description": "The document's tags, verbatim: publisher-authored subject vocabulary, claims rather than verified facts." + }, + "canonicalUrl": { + "type": "string", + "format": "uri", + "description": "The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here." } } }, @@ -284,6 +289,11 @@ "type": "array", "items": { "type": "string", "maxLength": 640, "maxGraphemes": 64 }, "description": "The document's tags, verbatim: publisher-authored subject vocabulary, claims rather than verified facts." + }, + "canonicalUrl": { + "type": "string", + "format": "uri", + "description": "The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here." } } }, diff --git a/openspec/changes/changelog-adoption/specs/changelog-authoring/spec.md b/openspec/changes/changelog-adoption/specs/changelog-authoring/spec.md index a86a4be..c876187 100644 --- a/openspec/changes/changelog-adoption/specs/changelog-authoring/spec.md +++ b/openspec/changes/changelog-adoption/specs/changelog-authoring/spec.md @@ -15,3 +15,12 @@ - **WHEN** `changelog link` runs against a document that already links the target release - **THEN** the document is unchanged — no duplicate link, no write + +### Requirement: Adoption is reversible + +`disttown changelog unlink --doc ` SHALL remove the `town.dist.defs#releaseLink` targeting the release from the document's `links` — and nothing else. Unlinking a document that does not link the release is a no-op, not an error. Because a later-rkey adoption shadows an earlier changelog, unlink is the undo that restores the original without deleting the adopted document. + +#### Scenario: Unlink restores the shadowed changelog + +- **WHEN** a test document adopted onto a release is unlinked +- **THEN** the release's original changelog (the next-greatest-rkey document backlinking it) serves again, and the unlinked document survives with its content and tags intact diff --git a/openspec/changes/changelog-adoption/specs/changelog-integration/spec.md b/openspec/changes/changelog-adoption/specs/changelog-integration/spec.md index 8e8339a..71c3f19 100644 --- a/openspec/changes/changelog-adoption/specs/changelog-integration/spec.md +++ b/openspec/changes/changelog-adoption/specs/changelog-integration/spec.md @@ -10,3 +10,12 @@ Ingest SHALL parse a changelog document's `tags`, and both changelog views — t - **WHEN** a document tagged by its author (in any standard.site tool) is linked to a release - **THEN** `resolveRelease` and `listChangelogs` responses carry those tags verbatim + +### Requirement: Unrenderable documents point home + +Changelog views SHALL carry `canonicalUrl` — the document's `site` + `path` composed when `site` is an http(s) url and `path` is present — so consumers can reach the document's home when its content cannot be rendered. An adopted document whose content union is unsupported (no markpub markdown, no plaintext) degrades to title, tags, and the outlink, never to silent emptiness. + +#### Scenario: A Leaflet-format document keeps a way in + +- **WHEN** a document whose content is an unsupported union member (e.g. `pub.leaflet.content`) is adopted as a changelog +- **THEN** its view carries the composed canonical url, and no view pretends the document has no content diff --git a/openspec/changes/changelog-adoption/specs/consumer-surfaces/spec.md b/openspec/changes/changelog-adoption/specs/consumer-surfaces/spec.md index 83793eb..76f9f59 100644 --- a/openspec/changes/changelog-adoption/specs/consumer-surfaces/spec.md +++ b/openspec/changes/changelog-adoption/specs/consumer-surfaces/spec.md @@ -15,3 +15,12 @@ Changelog surfaces (the stream entries and the release page's changelog) SHALL r - **WHEN** a document tagged `security` renders - **THEN** the chip is set in the ordinary ink register — no `--bad`/`--warn` color, no badge treatment — because the tag is the publisher's claim, not a verified fact + +### Requirement: Unrenderable changelogs link out, not blank out + +When a changelog document offers neither rendered HTML nor plaintext, the changelog surfaces SHALL say so plainly and link to the document's canonical home (`canonicalUrl`) instead of rendering an empty body. The lens does not pretend the document is empty; it points at where the document lives. + +#### Scenario: An adopted Leaflet post reads at its home + +- **WHEN** a release's changelog is a document written in an unsupported format +- **THEN** the surface shows its title and tags with a quiet line linking out to the canonical page, on both the stream and the release page diff --git a/openspec/changes/changelog-adoption/tasks.md b/openspec/changes/changelog-adoption/tasks.md index 857466d..fb34992 100644 --- a/openspec/changes/changelog-adoption/tasks.md +++ b/openspec/changes/changelog-adoption/tasks.md @@ -16,3 +16,8 @@ ## 4. Convergence and proof - [ ] 4.1 Re-author the dogfood documents (shed `cli`, gain `changelog`); adopt one externally-authored document via `changelog link` end to end; re-verify both in standard-reader.app and on dist.town + +## 5. Adoption round-trip (field findings, 2026-08-17) + +- [x] 5.1 `disttown changelog unlink --doc `: remove the releaseLink, touching nothing else; idempotent — adoption's undo, so shadowing is reversible without deleting the document +- [x] 5.2 Unsupported-markup outlink: views compose `canonicalUrl` (site + path when site is an http(s) url); surfaces with neither html nor textContent link out to the document's home instead of rendering empty prose diff --git a/packages/client/src/generated/lexicons.ts b/packages/client/src/generated/lexicons.ts index 86e381d..b4e14d3 100644 --- a/packages/client/src/generated/lexicons.ts +++ b/packages/client/src/generated/lexicons.ts @@ -249,6 +249,12 @@ export const schemaDict = { description: "The document's tags, verbatim: publisher-authored subject vocabulary, claims rather than verified facts.", }, + canonicalUrl: { + type: 'string', + format: 'uri', + description: + "The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here.", + }, }, }, releaseView: { @@ -486,6 +492,12 @@ export const schemaDict = { description: "The document's tags, verbatim: publisher-authored subject vocabulary, claims rather than verified facts.", }, + canonicalUrl: { + type: 'string', + format: 'uri', + description: + "The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here.", + }, }, }, changelogReleaseRef: { diff --git a/packages/client/src/generated/types/town/dist/defs.ts b/packages/client/src/generated/types/town/dist/defs.ts index ec00a89..3e6211a 100644 --- a/packages/client/src/generated/types/town/dist/defs.ts +++ b/packages/client/src/generated/types/town/dist/defs.ts @@ -202,6 +202,8 @@ export interface ChangelogView { publishedAt?: string /** The document's tags, verbatim: publisher-authored subject vocabulary, claims rather than verified facts. */ tags?: string[] + /** The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here. */ + canonicalUrl?: string } const hashChangelogView = 'changelogView' @@ -323,6 +325,8 @@ export interface ChangelogDocView { releases: ChangelogReleaseRef[] /** The document's tags, verbatim: publisher-authored subject vocabulary, claims rather than verified facts. */ tags?: string[] + /** The document's home (site + path), present when composable. The way in when the document's content format cannot be rendered here. */ + canonicalUrl?: string } const hashChangelogDocView = 'changelogDocView' diff --git a/web/src/lib/components/ChangelogProse.svelte b/web/src/lib/components/ChangelogProse.svelte index edc4b72..f9fb1ba 100644 --- a/web/src/lib/components/ChangelogProse.svelte +++ b/web/src/lib/components/ChangelogProse.svelte @@ -1,9 +1,14 @@
{#if html} {@html html} - {:else} + {:else if paragraphs.length > 0} {#each paragraphs as p, i (i)}

{p}

{/each} + {:else if canonicalUrl} +

+ This document is written in a format dist.town doesn't render. + Read it at {homeHost} → +

{/if}
diff --git a/web/src/lib/components/ChangelogStreamPage.svelte b/web/src/lib/components/ChangelogStreamPage.svelte index 55ce6c9..18b2efd 100644 --- a/web/src/lib/components/ChangelogStreamPage.svelte +++ b/web/src/lib/components/ChangelogStreamPage.svelte @@ -50,7 +50,7 @@ {doc.title} {/if} - +

{formatDate(tidToDate(doc.rkey) ?? new Date(doc.publishedAt))} diff --git a/web/src/lib/components/ReleasePage.svelte b/web/src/lib/components/ReleasePage.svelte index 720003a..9ca477a 100644 --- a/web/src/lib/components/ReleasePage.svelte +++ b/web/src/lib/components/ReleasePage.svelte @@ -87,7 +87,11 @@ {/each}

{/if} - +
{/if} diff --git a/web/src/routes/docs/cli/+page.md b/web/src/routes/docs/cli/+page.md index 47ab988..684e357 100644 --- a/web/src/routes/docs/cli/+page.md +++ b/web/src/routes/docs/cli/+page.md @@ -102,6 +102,17 @@ Adopt an existing document as a release's changelog — appends the release link - `--project ` — project name (required) - `--doc ` — the document to adopt: an rkey or at-uri in your own repo (required) +### changelog unlink + +```sh +disttown changelog unlink --project --doc +``` + +Detach a document from a release — removes the release link, changes nothing else. + +- `--project ` — project name (required) +- `--doc ` — the document to detach: an rkey or at-uri in your own repo (required) + ## project Manage project records. -- 2.51.2