···11+import { describe, expect, it } from 'vitest';
22+import {
33+ ATMOSPHERE_WEB_BASE,
44+ atmosphereProfileUrl,
55+ atmosphereHashtagUrl,
66+ atmospherePostWebUrl,
77+} from './atmosphere-url';
88+99+describe( 'atmosphere-url', () => {
1010+ it( 'builds a profile URL from a handle', () => {
1111+ expect( atmosphereProfileUrl( 'alice.bsky.social' ) ).toBe(
1212+ 'https://mu.social/profile/alice.bsky.social'
1313+ );
1414+ } );
1515+1616+ it( 'builds a hashtag URL from a bare tag', () => {
1717+ expect( atmosphereHashtagUrl( 'design' ) ).toBe(
1818+ 'https://mu.social/hashtag/design'
1919+ );
2020+ } );
2121+2222+ it( 'maps an at:// post uri to its profile/post URL', () => {
2323+ expect(
2424+ atmospherePostWebUrl( 'at://did:plc:writer/app.bsky.feed.post/3kpost' )
2525+ ).toBe( 'https://mu.social/profile/did:plc:writer/post/3kpost' );
2626+ } );
2727+2828+ it( 'falls back to the AppView home for an unparseable uri', () => {
2929+ expect( atmospherePostWebUrl( 'not-an-at-uri' ) ).toBe( ATMOSPHERE_WEB_BASE );
3030+ } );
3131+} );
+32
src/lib/social/atmosphere-url.ts
···11+/**
22+ * Web URLs for *viewing* AT Protocol records in an AppView client.
33+ *
44+ * SkyPress publishes to the Bluesky/atproto network, but links readers to mu.social
55+ * (a Bluesky AppView) to view profiles, posts, and hashtags. mu.social uses the same
66+ * path scheme as bsky.app. Keeping the host here means changing it again is a one-line
77+ * edit. This module is dependency-free so the reader path can import it (Decision 0003).
88+ */
99+export const ATMOSPHERE_WEB_BASE = 'https://mu.social';
1010+1111+/** Profile page for a handle (e.g. `alice.bsky.social`). */
1212+export function atmosphereProfileUrl( handle: string ): string {
1313+ return `${ ATMOSPHERE_WEB_BASE }/profile/${ encodeURIComponent( handle ) }`;
1414+}
1515+1616+/** Hashtag page for a bare tag (no leading `#`). */
1717+export function atmosphereHashtagUrl( tag: string ): string {
1818+ return `${ ATMOSPHERE_WEB_BASE }/hashtag/${ encodeURIComponent( tag ) }`;
1919+}
2020+2121+/**
2222+ * Web URL for a post AT-URI, for "view on the ATmosphere" links.
2323+ * `at://<did>/app.bsky.feed.post/<rkey>` → `<base>/profile/<did>/post/<rkey>`.
2424+ * The DID is kept raw (not encoded) so the path matches the AppView's canonical form.
2525+ * Falls back to the AppView home for an unparseable URI.
2626+ */
2727+export function atmospherePostWebUrl( postUri: string ): string {
2828+ const match = postUri.match( /^at:\/\/([^/]+)\/app\.bsky\.feed\.post\/(.+)$/ );
2929+ return match
3030+ ? `${ ATMOSPHERE_WEB_BASE }/profile/${ match[ 1 ] }/post/${ match[ 2 ] }`
3131+ : ATMOSPHERE_WEB_BASE;
3232+}