From fc6101c41f7a15c4777c1352a9019ecafee2d309 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 24 Jul 2026 03:54:15 +0000 Subject: [PATCH] Add AT Tags support to the website and browser extension AT Tags (https://tangled.org/chrisshank.com/at-tags/) is a community proposal for mapping a web page back to the atproto records and identities it references, via tags: the standard canonical / alternate / author / me properties plus namespaced at:{namespace}:{property}, with array semantics. - Shared, dependency-light parser/builder in src/utils/atproto/atTags.ts, imported by both surfaces. Recognizes the standard and namespaced properties (case-insensitive prefix/relation, case-preserving namespace/property), applies array semantics, and drops invalid AT URIs and unrecognized properties per the proposal. - Website emits AT Tags so aturi.to pages map back to atproto: record and post pages declare at:canonical (the record) and at:author (the repo DID); profile pages declare at:author. - Extension Inspect tab detects AT Tags, ranks them as the most authoritative DOM signal, dedupes per URI with the most authoritative relation, and badges each hit with its relationship. The Waypoints jump flow prefers a page's declared at:canonical record. - Tests for the parser/builder and the scanner integration. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01124waErXCc7H5UGUmpM83k --- README.md | 2 +- extension/README.md | 8 + extension/entrypoints/detect-head.content.ts | 31 +- extension/entrypoints/popup/InspectView.tsx | 14 +- extension/entrypoints/popup/popup.css | 20 ++ extension/lib/__tests__/atTags.test.ts | 267 ++++++++++++++++++ .../lib/__tests__/inspectScanner.test.ts | 83 ++++++ extension/lib/inspectScanner.ts | 91 +++++- src/app/[handle]/[collection]/[rkey]/page.tsx | 8 + src/app/[handle]/page.tsx | 6 + src/utils/atproto/atTags.ts | 262 +++++++++++++++++ src/utils/postMetadata.ts | 8 + 12 files changed, 783 insertions(+), 17 deletions(-) create mode 100644 extension/lib/__tests__/atTags.test.ts create mode 100644 src/utils/atproto/atTags.ts diff --git a/README.md b/README.md index 29fce0d..904038a 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ The extension is a core part of Aturi: for many users it's the primary way they - **One-click jump.** Land on a Bluesky post and want to read it in Anisota? Click the leaf in your toolbar. The popup detects the AT URI on the page and offers every other Atmosphere waypoint that can render it: every other Bluesky fork, plus Leaflet, Tangled, Margin, Grain, and the rest. Click one and it opens in a new tab. - **Auto-redirect.** Flip a switch and links get silently rewritten to your preferred client *before* they load. Pick a favorite per data family (Bluesky-style clients, Publications, Tangled, Margin, Grain, Pinkleap, Semble, Streamplace, Popfeed, Sifa, Blento). Powered by `chrome.declarativeNetRequest`, so it's fast and doesn't read your browsing history. -- **Inspect mode.** Open the Inspect tab to see the underlying AT URI for whatever's on screen: the DID behind the handle, its PDS, the lexicon collection, the record JSON, and the inbound backlinks count from [Constellation](https://constellation.microcosm.blue). Tap any field to copy it, or jump straight into the Atmosphere Explorer for the raw record. +- **Inspect mode.** Open the Inspect tab to see the underlying AT URI for whatever's on screen: the DID behind the handle, its PDS, the lexicon collection, the record JSON, and the inbound backlinks count from [Constellation](https://constellation.microcosm.blue). Tap any field to copy it, or jump straight into the Atmosphere Explorer for the raw record. When a page declares its records with [AT Tags](https://tangled.org/chrisshank.com/at-tags/) (`` and friends), the extension reads them first and labels each hit with its relationship (canonical, author, and so on), falling back to scanning links, meta tags, and page text. - **Custom waypoints.** Wire up any site that uses a consistent URL structure via templates like `/profile/{handle}` or `/u/{handle}/p/{rkey}`. Templates work in both directions: the extension generates outbound links *and* reverse-matches inbound ones, so custom waypoints are fully supported in the popup, auto-redirect, and visibility controls. - **Visibility, groups & ordering.** Hide waypoints you'll never use, group the rest however you like, drag-and-drop to reorder, and the popup surfaces your most-used destinations first. - **Local-first.** No account, no telemetry, no background network calls. The extension only talks to public atproto services when you open the popup or hit Inspect. Preferences live in your browser's local storage. See [`extension/PRIVACY.txt`](extension/PRIVACY.txt) or [aturi.to/extension/privacy](https://aturi.to/extension/privacy). diff --git a/extension/README.md b/extension/README.md index 5aaa9ce..673b046 100644 --- a/extension/README.md +++ b/extension/README.md @@ -24,6 +24,14 @@ auto-redirect links between them. - **Custom waypoints**: wire up any site that uses a consistent URL structure via URL templates (`/profile/{handle}`, `/u/{handle}/p/{rkey}`, etc.). - **Recents**: the popup surfaces the waypoints you use most often first. +- **AT Tags**: the Inspect tab reads the + [AT Tags](https://tangled.org/chrisshank.com/at-tags/) a page declares about + itself (`` and the `at:alternate`, + `at:author`, `at:me`, and namespaced `at:{namespace}:{property}` siblings) and + labels each detected URI with its relationship. The popup's jump flow also + prefers a page's `at:canonical` record when one is declared. Parsing lives in + the shared `src/utils/atproto/atTags.ts`, so the web app emits the same tags on + its record and profile pages. ## Development diff --git a/extension/entrypoints/detect-head.content.ts b/extension/entrypoints/detect-head.content.ts index 2d65891..6f4467b 100644 --- a/extension/entrypoints/detect-head.content.ts +++ b/extension/entrypoints/detect-head.content.ts @@ -1,10 +1,19 @@ import { defineContentScript } from '#imports'; +import { parseAtTagsFromDocument, isDidOnlyAtUri } from '@aturi/atproto/atTags'; /** - * Content script that scans the page's for tags containing - * AT URIs (at://...). This enables detection of atmosphere apps like Offprint, - * pckt, and Leaflet/standard.site pages that embed their AT URI in the document - * head rather than the URL path. + * Content script that discovers the AT URI a page is "about" so the popup's + * Waypoints tab can offer to open it in another client. Two signals, in + * priority order: + * + * 1. AT Tags (https://tangled.org/chrisshank.com/at-tags/): the page's own + * `` declaration, falling + * back to `at:alternate`. This is the record the page is rendering. + * 2. Legacy `` in , as used by Offprint, pckt, + * and Leaflet/standard.site pages before the AT Tags proposal. + * + * Author/me tags are deliberately ignored here: they point at a DID, not a + * record, so they'd only surface profile waypoints for a record page. * * Communicates findings back to the popup/background via runtime messaging. */ @@ -13,6 +22,20 @@ export default defineContentScript({ runAt: 'document_end', main() { function findAtUriInHead(): string | null { + // 1. AT Tags canonical/alternate — the record this page displays. Skip + // DID-only values (a spec-violating canonical points at a bare + // identity) so the jump flow surfaces record waypoints, not a profile. + try { + const tags = parseAtTagsFromDocument(document); + const record = [...tags.canonical, ...tags.alternate].find( + (uri) => !isDidOnlyAtUri(uri), + ); + if (record) return record; + } catch { + /* fall through to the legacy scan */ + } + + // 2. Legacy alternate link. const links = document.querySelectorAll('head link[href^="at://"]'); for (const link of links) { const href = link.getAttribute('href'); diff --git a/extension/entrypoints/popup/InspectView.tsx b/extension/entrypoints/popup/InspectView.tsx index d428a26..2c3aeea 100644 --- a/extension/entrypoints/popup/InspectView.tsx +++ b/extension/entrypoints/popup/InspectView.tsx @@ -15,6 +15,7 @@ import { RefreshCw, Repeat2, Server, + Tag, Telescope, } from 'lucide-react'; import { parseAtUri, encodeRepo, shortDid } from '@aturi/atproto/urls'; @@ -340,8 +341,19 @@ function InspectCard({ hit }: { hit: DetectedAtUri }) { return (
{/* Breadcrumb: PDS host › @handle › collection › rkey. Each segment - is a deep link into the explorer at the appropriate depth. */} + is a deep link into the explorer at the appropriate depth. A + leading relation pill appears when the page declared this URI via + the AT Tags proposal (at:canonical / at:author / …). */}
+ {hit.relation && ( + + + {hit.relation} + + )} {effectivePdsHost && pdsExplorerUrl && ( <> ` declaration (canonical / author / me / namespaced). + Sits at the head of the breadcrumb as a small accent pill. */ +.inspect-relation { + display: inline-flex; + align-items: center; + gap: 3px; + flex-shrink: 0; + padding: 1px 6px; + font-family: var(--font-mono); + font-size: 9px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--text-accent); + background: var(--bg-accent-subtle, rgba(120, 120, 200, 0.12)); + border: 1px solid var(--border-subtle); + border-radius: 0; +} + .inspect-preview { font-size: 12px; color: var(--text-secondary); diff --git a/extension/lib/__tests__/atTags.test.ts b/extension/lib/__tests__/atTags.test.ts new file mode 100644 index 0000000..527dbb2 --- /dev/null +++ b/extension/lib/__tests__/atTags.test.ts @@ -0,0 +1,267 @@ +import { describe, it, expect } from 'vitest'; +import { parseHTML } from 'linkedom'; +import { + parseAtTagName, + parseAtTags, + parseAtTagsFromDocument, + buildAtTagsMetadata, + isValidAtUri, + isDidOnlyAtUri, + type MetaEntry, +} from '@aturi/atproto/atTags'; + +function docFrom(html: string): Document { + return parseHTML(html).document as unknown as Document; +} + +const RECORD = 'at://did:plc:abc123/site.standard.document/rkey'; +const RECORD_2 = 'at://did:plc:xyz789/site.standard.publication/rkey'; +const AUTHOR = 'at://did:plc:author'; +const ME = 'at://did:plc:my-did'; + +describe('parseAtTagName', () => { + it('recognizes the four standard properties', () => { + expect(parseAtTagName('at:canonical')).toEqual({ kind: 'standard', relation: 'canonical' }); + expect(parseAtTagName('at:alternate')).toEqual({ kind: 'standard', relation: 'alternate' }); + expect(parseAtTagName('at:author')).toEqual({ kind: 'standard', relation: 'author' }); + expect(parseAtTagName('at:me')).toEqual({ kind: 'standard', relation: 'me' }); + }); + + it('is case-insensitive and trims whitespace', () => { + expect(parseAtTagName(' AT:Canonical ')).toEqual({ kind: 'standard', relation: 'canonical' }); + }); + + it('parses namespaced properties as at:{namespace}:{property}', () => { + expect(parseAtTagName('at:standard.site:comments')).toEqual({ + kind: 'namespaced', + namespace: 'standard.site', + property: 'comments', + }); + expect(parseAtTagName('at:standard.site:syndicated-by')).toEqual({ + kind: 'namespaced', + namespace: 'standard.site', + property: 'syndicated-by', + }); + }); + + it('preserves the original case of namespace and property segments', () => { + // Only the `at:` prefix and standard-relation keywords are lowercased; + // case-sensitive property names must survive intact. + expect(parseAtTagName('AT:com.example.myApp:syndicatedBy')).toEqual({ + kind: 'namespaced', + namespace: 'com.example.myApp', + property: 'syndicatedBy', + }); + }); + + it('ignores non-at names', () => { + expect(parseAtTagName('og:title')).toBeNull(); + expect(parseAtTagName('twitter:card')).toBeNull(); + expect(parseAtTagName('description')).toBeNull(); + expect(parseAtTagName(null)).toBeNull(); + expect(parseAtTagName(undefined)).toBeNull(); + }); + + it('ignores at properties that are neither standard nor namespaced', () => { + // Per the proposal: "Any at property that is not part of the standard and + // not namespaced should be ignored." + expect(parseAtTagName('at:')).toBeNull(); + expect(parseAtTagName('at:bogus')).toBeNull(); + expect(parseAtTagName('at:standard.site:')).toBeNull(); // empty property + expect(parseAtTagName('at::comments')).toBeNull(); // empty namespace + }); +}); + +describe('parseAtTags', () => { + it('buckets standard properties', () => { + const result = parseAtTags([ + { name: 'at:canonical', content: RECORD }, + { name: 'at:alternate', content: RECORD_2 }, + { name: 'at:author', content: AUTHOR }, + { name: 'at:me', content: ME }, + ]); + expect(result.canonical).toEqual([RECORD]); + expect(result.alternate).toEqual([RECORD_2]); + expect(result.author).toEqual([AUTHOR]); + expect(result.me).toEqual([ME]); + expect(result.tags).toHaveLength(4); + }); + + it('follows array semantics for repeated properties', () => { + const result = parseAtTags([ + { name: 'at:canonical', content: RECORD }, + { name: 'at:canonical', content: RECORD_2 }, + { name: 'at:author', content: AUTHOR }, + { name: 'at:author', content: ME }, + ]); + expect(result.canonical).toEqual([RECORD, RECORD_2]); + expect(result.author).toEqual([AUTHOR, ME]); + }); + + it('deduplicates identical values for the same property', () => { + const result = parseAtTags([ + { name: 'at:canonical', content: RECORD }, + { name: 'at:canonical', content: RECORD }, + ]); + expect(result.canonical).toEqual([RECORD]); + expect(result.tags).toHaveLength(1); + }); + + it('drops tags whose content is not a valid AT URI', () => { + const result = parseAtTags([ + { name: 'at:canonical', content: 'https://example.com/thing' }, + { name: 'at:canonical', content: 'not a uri' }, + { name: 'at:canonical', content: '' }, + { name: 'at:canonical', content: RECORD }, + ]); + expect(result.canonical).toEqual([RECORD]); + }); + + it('nests namespaced properties under namespace -> property -> uris', () => { + const result = parseAtTags([ + { name: 'at:standard.site:comments', content: RECORD }, + { name: 'at:standard.site:syndicated-by', content: RECORD_2 }, + { name: 'at:standard.site:comments', content: RECORD_2 }, + ]); + expect(result.namespaces['standard.site'].comments).toEqual([RECORD, RECORD_2]); + expect(result.namespaces['standard.site']['syndicated-by']).toEqual([RECORD_2]); + }); + + it('ignores unrecognized at properties entirely', () => { + const result = parseAtTags([{ name: 'at:bogus', content: RECORD }]); + expect(result.tags).toHaveLength(0); + expect(result.canonical).toEqual([]); + }); + + it('records a flat, source-ordered tag list', () => { + const entries: MetaEntry[] = [ + { name: 'at:author', content: AUTHOR }, + { name: 'at:canonical', content: RECORD }, + { name: 'at:standard.site:comments', content: RECORD_2 }, + ]; + const result = parseAtTags(entries); + expect(result.tags.map((t) => t.name)).toEqual([ + 'at:author', + 'at:canonical', + 'at:standard.site:comments', + ]); + }); +}); + +describe('parseAtTagsFromDocument', () => { + it('reads at: meta tags off a live DOM', () => { + const doc = docFrom(` + + + + + + + + + + `); + const result = parseAtTagsFromDocument(doc); + expect(result.canonical).toEqual([RECORD]); + expect(result.alternate).toEqual([RECORD_2]); + expect(result.author).toEqual([AUTHOR]); + expect(result.me).toEqual([ME]); + expect(result.namespaces['standard.site'].comments).toEqual([RECORD_2]); + }); + + it('returns an empty result for a page with no at: tags', () => { + const doc = docFrom(``); + const result = parseAtTagsFromDocument(doc); + expect(result.tags).toHaveLength(0); + }); + + it('reads mixed-case at: names off the DOM (case-insensitive selection)', () => { + // A case-sensitive `meta[name^="at:"]` selector would silently drop this. + const doc = docFrom(` + + + + `); + const result = parseAtTagsFromDocument(doc); + expect(result.canonical).toEqual([RECORD]); + }); +}); + +describe('isValidAtUri / isDidOnlyAtUri', () => { + it('accepts DID and handle authorities', () => { + expect(isValidAtUri(RECORD)).toBe(true); + expect(isValidAtUri(AUTHOR)).toBe(true); + expect(isValidAtUri('at://alice.bsky.social/app.bsky.feed.post/xyz')).toBe(true); + }); + + it('rejects non-at URIs and junk authorities', () => { + expect(isValidAtUri('https://example.com')).toBe(false); + expect(isValidAtUri('at://')).toBe(false); + expect(isValidAtUri('at://foo')).toBe(false); // no dot, not a DID + expect(isValidAtUri('')).toBe(false); + expect(isValidAtUri(null)).toBe(false); + }); + + it('distinguishes DID-only URIs from record URIs', () => { + expect(isDidOnlyAtUri(AUTHOR)).toBe(true); + expect(isDidOnlyAtUri(ME)).toBe(true); + expect(isDidOnlyAtUri(RECORD)).toBe(false); // has collection + rkey + expect(isDidOnlyAtUri('at://alice.bsky.social')).toBe(false); // handle, not DID + }); +}); + +describe('buildAtTagsMetadata', () => { + it('emits a single string for one value and an array for many', () => { + const meta = buildAtTagsMetadata({ + canonical: RECORD, + author: [AUTHOR, ME], + }); + expect(meta['at:canonical']).toBe(RECORD); + expect(meta['at:author']).toEqual([AUTHOR, ME]); + }); + + it('drops invalid AT URIs and omits empty properties', () => { + const meta = buildAtTagsMetadata({ + canonical: 'https://example.com', + author: AUTHOR, + me: null, + }); + expect(meta['at:canonical']).toBeUndefined(); + expect(meta['at:author']).toBe(AUTHOR); + expect(meta['at:me']).toBeUndefined(); + }); + + it('emits namespaced tags with the at:{namespace}:{property} name', () => { + const meta = buildAtTagsMetadata({ + canonical: RECORD, + namespaces: { + 'standard.site': { comments: RECORD_2, 'syndicated-by': [RECORD, RECORD_2] }, + }, + }); + expect(meta['at:standard.site:comments']).toBe(RECORD_2); + expect(meta['at:standard.site:syndicated-by']).toEqual([RECORD, RECORD_2]); + }); + + it('round-trips: built metadata parses back to the same values', () => { + const meta = buildAtTagsMetadata({ canonical: [RECORD, RECORD_2], author: AUTHOR }); + const entries: MetaEntry[] = Object.entries(meta).flatMap(([name, value]) => + (Array.isArray(value) ? value : [value]).map((content) => ({ name, content })), + ); + const parsed = parseAtTags(entries); + expect(parsed.canonical).toEqual([RECORD, RECORD_2]); + expect(parsed.author).toEqual([AUTHOR]); + }); + + it('round-trips namespaced properties with case-sensitive segments', () => { + const meta = buildAtTagsMetadata({ + namespaces: { 'com.example.myApp': { syndicatedBy: RECORD } }, + }); + expect(meta['at:com.example.myApp:syndicatedBy']).toBe(RECORD); + const entries: MetaEntry[] = Object.entries(meta).flatMap(([name, value]) => + (Array.isArray(value) ? value : [value]).map((content) => ({ name, content })), + ); + const parsed = parseAtTags(entries); + // The exact-case key must be recoverable — no silent lowercasing. + expect(parsed.namespaces['com.example.myApp'].syndicatedBy).toEqual([RECORD]); + }); +}); diff --git a/extension/lib/__tests__/inspectScanner.test.ts b/extension/lib/__tests__/inspectScanner.test.ts index 8d15a0b..97339b0 100644 --- a/extension/lib/__tests__/inspectScanner.test.ts +++ b/extension/lib/__tests__/inspectScanner.test.ts @@ -67,6 +67,89 @@ describe('scanDocumentForAtUrisFast', () => { }); }); +describe('AT Tags (at: meta) detection', () => { + const CANON = 'at://did:plc:abc123/site.standard.document/rkey'; + const AUTHOR = 'at://did:plc:author'; + + it('detects at:canonical/at:author and labels the relation', () => { + const doc = docFrom(` + + + + + `); + const hits = scanDocumentForAtUrisFast(doc); + const canon = hits.find((h) => h.uri === CANON); + const author = hits.find((h) => h.uri === AUTHOR); + expect(canon?.where).toBe('at-tags'); + expect(canon?.relation).toBe('canonical'); + expect(author?.where).toBe('at-tags'); + expect(author?.relation).toBe('author'); + }); + + it('labels namespaced relations as namespace:property', () => { + const doc = docFrom(` + + + + `); + const hits = scanDocumentForAtUrisFast(doc); + expect(hits[0].relation).toBe('standard.site:comments'); + }); + + it('ranks an at:canonical declaration above a bare for the same URI', () => { + const doc = docFrom(` + + + + + `); + const merged = dedupeByUri(scanDocumentForAtUris(doc)); + const hit = merged.find((h) => h.uri === CANON); + expect(hit?.where).toBe('at-tags'); + expect(hit?.relation).toBe('canonical'); + }); + + it('keeps one detection per URI, labeled with the most authoritative relation', () => { + // Same DID declared as both author and me; canonical also shares a URI with + // a namespaced relation that appears first in the document. + const SELF = 'at://did:plc:me'; + const doc = docFrom(` + + + + + + + `); + const hits = scanDocumentForAtUrisFast(doc).filter((h) => h.where === 'at-tags'); + const canon = hits.filter((h) => h.uri === CANON); + const self = hits.filter((h) => h.uri === SELF); + expect(canon).toHaveLength(1); + expect(canon[0].relation).toBe('canonical'); // beats standard.site:comments + expect(self).toHaveLength(1); + expect(self[0].relation).toBe('author'); // beats me + }); + + it('orders at-tags ahead of a URL-pattern hit for a different URI', () => { + const OTHER = 'at://did:plc:other/site.standard.document/x'; + const doc = docFrom(` + + + + `); + // Simulate the popup path: a URL-pattern hit collected first, then the + // content-script DOM hits appended. + const hits = [ + { uri: OTHER, where: 'url' as const }, + ...scanDocumentForAtUris(doc), + ]; + const merged = dedupeByUri(hits); + expect(merged[0].where).toBe('at-tags'); + expect(merged[0].uri).toBe(CANON); + }); +}); + describe('scanDocumentForAtUris (full)', () => { it('does pick up body-text URIs that the fast scan ignores', () => { const doc = docFrom(` diff --git a/extension/lib/inspectScanner.ts b/extension/lib/inspectScanner.ts index 1075652..364d491 100644 --- a/extension/lib/inspectScanner.ts +++ b/extension/lib/inspectScanner.ts @@ -4,16 +4,23 @@ * of detected AT URIs back to the popup for display. * * Bucket meanings: - * - 'url' : the page URL itself matched a known atmosphere app pattern. - * - 'head' : in . - * - 'meta' : OpenGraph / Twitter meta tags with an at:// value. - * - 'link' : anywhere on the page. - * - 'jsonld': inside a