@rmdes/indiekit-endpoint-activitypub 1.1.17 → 1.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,6 +93,7 @@ export default {
93
93
  | `redisUrl` | string | `""` | Redis connection URL for delivery queue |
94
94
  | `parallelWorkers` | number | `5` | Number of parallel delivery workers (requires Redis) |
95
95
  | `actorType` | string | `"Person"` | Actor type: `Person`, `Service`, `Organization`, or `Group` |
96
+ | `logLevel` | string | `"warning"` | Fedify log level: `"debug"`, `"info"`, `"warning"`, `"error"`, `"fatal"` |
96
97
  | `timelineRetention` | number | `1000` | Maximum timeline items to keep (0 = unlimited) |
97
98
 
98
99
  ### Redis (Recommended for Production)
package/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import express from "express";
2
2
 
3
- import { setupFederation } from "./lib/federation-setup.js";
3
+ import { setupFederation, buildPersonActor } from "./lib/federation-setup.js";
4
4
  import {
5
5
  createFedifyMiddleware,
6
6
  } from "./lib/federation-bridge.js";
@@ -58,6 +58,7 @@ import {
58
58
  featuredTagsRemoveController,
59
59
  } from "./lib/controllers/featured-tags.js";
60
60
  import { resolveController } from "./lib/controllers/resolve.js";
61
+ import { publicProfileController } from "./lib/controllers/public-profile.js";
61
62
  import {
62
63
  refollowPauseController,
63
64
  refollowResumeController,
@@ -82,6 +83,7 @@ const defaults = {
82
83
  redisUrl: "",
83
84
  parallelWorkers: 5,
84
85
  actorType: "Person",
86
+ logLevel: "warning",
85
87
  timelineRetention: 1000,
86
88
  notificationRetentionDays: 30,
87
89
  };
@@ -157,6 +159,10 @@ export default class ActivityPubEndpoint {
157
159
  return self._fedifyMiddleware(req, res, next);
158
160
  });
159
161
 
162
+ // HTML fallback for actor URL — serve a public profile page.
163
+ // Fedify only serves JSON-LD; browsers get 406 and fall through here.
164
+ router.get("/users/:identifier", publicProfileController(self));
165
+
160
166
  // Catch-all for federation paths that Fedify didn't handle (e.g. GET
161
167
  // on inbox). Without this, they fall through to Indiekit's auth
162
168
  // middleware and redirect to the login page.
@@ -677,6 +683,10 @@ export default class ActivityPubEndpoint {
677
683
  * Send an Update(Person) activity to all followers so remote servers
678
684
  * re-fetch the actor object (picking up profile changes, new featured
679
685
  * collections, attachments, etc.).
686
+ *
687
+ * Delivery is batched to avoid a thundering herd: hundreds of remote
688
+ * servers simultaneously re-fetching the actor, featured posts, and
689
+ * featured tags after receiving the Update all at once.
680
690
  */
681
691
  async broadcastActorUpdate() {
682
692
  if (!this._federation) return;
@@ -689,9 +699,15 @@ export default class ActivityPubEndpoint {
689
699
  { handle, publicationUrl: this._publicationUrl },
690
700
  );
691
701
 
692
- // Retrieve the full actor from the dispatcher (same object remote
693
- // servers will get when they re-fetch the actor URL)
694
- const actor = await ctx.getActor(handle);
702
+ // Build the full actor object (same as what the dispatcher serves).
703
+ // Note: ctx.getActor() only exists on RequestContext, not the base
704
+ // Context returned by createContext(), so we use the shared helper.
705
+ const actor = await buildPersonActor(
706
+ ctx,
707
+ handle,
708
+ this._collections,
709
+ this.options.actorType,
710
+ );
695
711
  if (!actor) {
696
712
  console.warn("[ActivityPub] broadcastActorUpdate: could not build actor");
697
713
  return;
@@ -702,21 +718,80 @@ export default class ActivityPubEndpoint {
702
718
  object: actor,
703
719
  });
704
720
 
705
- await ctx.sendActivity(
706
- { identifier: handle },
707
- "followers",
708
- update,
709
- { preferSharedInbox: true },
721
+ // Fetch followers and deduplicate by shared inbox so each remote
722
+ // server only gets one delivery (same as preferSharedInbox but
723
+ // gives us control over batching).
724
+ const followers = await this._collections.ap_followers
725
+ .find({})
726
+ .project({ actorUrl: 1, inbox: 1, sharedInbox: 1 })
727
+ .toArray();
728
+
729
+ // Group by shared inbox (or direct inbox if none)
730
+ const inboxMap = new Map();
731
+ for (const f of followers) {
732
+ const key = f.sharedInbox || f.inbox;
733
+ if (key && !inboxMap.has(key)) {
734
+ inboxMap.set(key, f);
735
+ }
736
+ }
737
+
738
+ const uniqueRecipients = [...inboxMap.values()];
739
+ const BATCH_SIZE = 25;
740
+ const BATCH_DELAY_MS = 5000;
741
+ let delivered = 0;
742
+ let failed = 0;
743
+
744
+ console.info(
745
+ `[ActivityPub] Broadcasting Update(Person) to ${uniqueRecipients.length} ` +
746
+ `unique inboxes (${followers.length} followers) in batches of ${BATCH_SIZE}`,
710
747
  );
711
748
 
712
- console.info("[ActivityPub] Sent Update(Person) to followers");
749
+ for (let i = 0; i < uniqueRecipients.length; i += BATCH_SIZE) {
750
+ const batch = uniqueRecipients.slice(i, i + BATCH_SIZE);
751
+
752
+ // Build Fedify-compatible Recipient objects:
753
+ // extractInboxes() reads: recipient.id, recipient.inboxId,
754
+ // recipient.endpoints?.sharedInbox
755
+ const recipients = batch.map((f) => ({
756
+ id: new URL(f.actorUrl),
757
+ inboxId: new URL(f.inbox || f.sharedInbox),
758
+ endpoints: f.sharedInbox
759
+ ? { sharedInbox: new URL(f.sharedInbox) }
760
+ : undefined,
761
+ }));
762
+
763
+ try {
764
+ await ctx.sendActivity(
765
+ { identifier: handle },
766
+ recipients,
767
+ update,
768
+ { preferSharedInbox: true },
769
+ );
770
+ delivered += batch.length;
771
+ } catch (error) {
772
+ failed += batch.length;
773
+ console.warn(
774
+ `[ActivityPub] Batch ${Math.floor(i / BATCH_SIZE) + 1} failed: ${error.message}`,
775
+ );
776
+ }
777
+
778
+ // Stagger batches so remote servers don't all re-fetch at once
779
+ if (i + BATCH_SIZE < uniqueRecipients.length) {
780
+ await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS));
781
+ }
782
+ }
783
+
784
+ console.info(
785
+ `[ActivityPub] Update(Person) broadcast complete: ` +
786
+ `${delivered} delivered, ${failed} failed`,
787
+ );
713
788
 
714
789
  await logActivity(this._collections.ap_activities, {
715
790
  direction: "outbound",
716
791
  type: "Update",
717
792
  actorUrl: this._publicationUrl,
718
793
  objectUrl: this._getActorUrl(),
719
- summary: "Sent Update(Person) to followers",
794
+ summary: `Sent Update(Person) to ${delivered}/${uniqueRecipients.length} inboxes`,
720
795
  }).catch(() => {});
721
796
  } catch (error) {
722
797
  console.error(
@@ -891,6 +966,7 @@ export default class ActivityPubEndpoint {
891
966
  publicationUrl: this._publicationUrl,
892
967
  parallelWorkers: this.options.parallelWorkers,
893
968
  actorType: this.options.actorType,
969
+ logLevel: this.options.logLevel,
894
970
  });
895
971
 
896
972
  this._federation = federation;
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Public profile controller — renders a standalone HTML profile page
3
+ * for browsers visiting the actor URL (e.g. /activitypub/users/rick).
4
+ *
5
+ * Fedify handles ActivityPub clients via content negotiation; browsers
6
+ * that send Accept: text/html fall through to this controller.
7
+ */
8
+
9
+ export function publicProfileController(plugin) {
10
+ return async (req, res, next) => {
11
+ const identifier = req.params.identifier;
12
+
13
+ // Only serve our own actor; unknown handles fall through to 404
14
+ if (identifier !== plugin.options.actor.handle) {
15
+ return next();
16
+ }
17
+
18
+ try {
19
+ const { application } = req.app.locals;
20
+ const collections = application.collections;
21
+
22
+ const apProfile = collections.get("ap_profile");
23
+ const apFollowers = collections.get("ap_followers");
24
+ const apFollowing = collections.get("ap_following");
25
+ const apFeatured = collections.get("ap_featured");
26
+ const postsCollection = collections.get("posts");
27
+
28
+ // Parallel queries for all profile data
29
+ const [profile, followerCount, followingCount, postCount, featuredDocs, recentPosts] =
30
+ await Promise.all([
31
+ apProfile ? apProfile.findOne({}) : null,
32
+ apFollowers ? apFollowers.countDocuments() : 0,
33
+ apFollowing ? apFollowing.countDocuments() : 0,
34
+ postsCollection ? postsCollection.countDocuments() : 0,
35
+ apFeatured
36
+ ? apFeatured.find().sort({ pinnedAt: -1 }).toArray()
37
+ : [],
38
+ postsCollection
39
+ ? postsCollection
40
+ .find()
41
+ .sort({ "properties.published": -1 })
42
+ .limit(20)
43
+ .toArray()
44
+ : [],
45
+ ]);
46
+
47
+ // Enrich pinned posts with title/type from posts collection
48
+ const pinned = [];
49
+ for (const doc of featuredDocs) {
50
+ if (!postsCollection) break;
51
+ const post = await postsCollection.findOne({
52
+ "properties.url": doc.postUrl,
53
+ });
54
+ if (post?.properties) {
55
+ pinned.push({
56
+ url: doc.postUrl,
57
+ title:
58
+ post.properties.name ||
59
+ post.properties.content?.text?.slice(0, 120) ||
60
+ doc.postUrl,
61
+ type: post.properties["post-type"] || "note",
62
+ published: post.properties.published,
63
+ });
64
+ }
65
+ }
66
+
67
+ const domain = new URL(plugin._publicationUrl).hostname;
68
+ const handle = plugin.options.actor.handle;
69
+
70
+ res.render("activitypub-public-profile", {
71
+ profile: profile || {},
72
+ handle,
73
+ domain,
74
+ fullHandle: `@${handle}@${domain}`,
75
+ actorUrl: `${plugin._publicationUrl}activitypub/users/${handle}`,
76
+ siteUrl: plugin._publicationUrl,
77
+ followerCount,
78
+ followingCount,
79
+ postCount,
80
+ pinned,
81
+ recentPosts: recentPosts.map((p) => p.properties),
82
+ });
83
+ } catch (error) {
84
+ next(error);
85
+ }
86
+ };
87
+ }
@@ -60,6 +60,7 @@ export function setupFederation(options) {
60
60
  publicationUrl = "",
61
61
  parallelWorkers = 5,
62
62
  actorType = "Person",
63
+ logLevel = "warning",
63
64
  } = options;
64
65
 
65
66
  // Map config string to Fedify actor class
@@ -67,6 +68,9 @@ export function setupFederation(options) {
67
68
  const ActorClass = actorTypeMap[actorType] || Person;
68
69
 
69
70
  // Configure LogTape for Fedify delivery logging (once per process)
71
+ // Valid levels: "debug" | "info" | "warning" | "error" | "fatal"
72
+ const validLevels = ["debug", "info", "warning", "error", "fatal"];
73
+ const resolvedLevel = validLevels.includes(logLevel) ? logLevel : "warning";
70
74
  if (!_logtapeConfigured) {
71
75
  _logtapeConfigured = true;
72
76
  configure({
@@ -79,7 +83,7 @@ export function setupFederation(options) {
79
83
  // All Fedify logs — federation, vocab, delivery, HTTP signatures
80
84
  category: ["fedify"],
81
85
  sinks: ["console"],
82
- lowestLevel: "info",
86
+ lowestLevel: resolvedLevel,
83
87
  },
84
88
  ],
85
89
  }).catch((error) => {
@@ -138,74 +142,7 @@ export function setupFederation(options) {
138
142
 
139
143
  if (identifier !== handle) return null;
140
144
 
141
- const profile = await getProfile(collections);
142
- const keyPairs = await ctx.getActorKeyPairs(identifier);
143
-
144
- const personOptions = {
145
- id: ctx.getActorUri(identifier),
146
- preferredUsername: identifier,
147
- name: profile.name || identifier,
148
- url: profile.url ? new URL(profile.url) : null,
149
- inbox: ctx.getInboxUri(identifier),
150
- outbox: ctx.getOutboxUri(identifier),
151
- followers: ctx.getFollowersUri(identifier),
152
- following: ctx.getFollowingUri(identifier),
153
- liked: ctx.getLikedUri(identifier),
154
- featured: ctx.getFeaturedUri(identifier),
155
- featuredTags: ctx.getFeaturedTagsUri(identifier),
156
- endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
157
- manuallyApprovesFollowers:
158
- profile.manuallyApprovesFollowers || false,
159
- };
160
-
161
- if (profile.summary) {
162
- personOptions.summary = profile.summary;
163
- }
164
-
165
- if (profile.icon) {
166
- personOptions.icon = new Image({
167
- url: new URL(profile.icon),
168
- mediaType: guessImageMediaType(profile.icon),
169
- });
170
- }
171
-
172
- if (profile.image) {
173
- personOptions.image = new Image({
174
- url: new URL(profile.image),
175
- mediaType: guessImageMediaType(profile.image),
176
- });
177
- }
178
-
179
- if (keyPairs.length > 0) {
180
- personOptions.publicKey = keyPairs[0].cryptographicKey;
181
- personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
182
- }
183
-
184
- if (profile.attachments?.length > 0) {
185
- personOptions.attachments = profile.attachments.map(
186
- (att) =>
187
- new PropertyValue({
188
- name: att.name,
189
- value: formatAttachmentValue(att.value),
190
- }),
191
- );
192
- }
193
-
194
- if (profile.alsoKnownAs?.length > 0) {
195
- personOptions.alsoKnownAs = profile.alsoKnownAs.map(
196
- (u) => new URL(u),
197
- );
198
- }
199
-
200
- if (profile.createdAt) {
201
- personOptions.published = Temporal.Instant.from(profile.createdAt);
202
- }
203
-
204
- // Actor type from profile overrides config default
205
- const profileActorType = profile.actorType || actorType;
206
- const ResolvedActorClass = actorTypeMap[profileActorType] || ActorClass;
207
-
208
- return new ResolvedActorClass(personOptions);
145
+ return buildPersonActor(ctx, identifier, collections, actorType);
209
146
  },
210
147
  )
211
148
  .mapHandle((_ctx, username) => {
@@ -674,6 +611,90 @@ async function getProfile(collections) {
674
611
  return doc || {};
675
612
  }
676
613
 
614
+ /**
615
+ * Build the Person/Service/Organization actor object from the stored profile.
616
+ * Used by both the actor dispatcher (for serving the actor to federation
617
+ * requests) and broadcastActorUpdate() (for sending Update activities).
618
+ *
619
+ * @param {object} ctx - Fedify context (base Context or RequestContext)
620
+ * @param {string} identifier - Actor handle (e.g. "rick")
621
+ * @param {object} collections - MongoDB collections
622
+ * @param {string} [defaultActorType="Person"] - Fallback actor type
623
+ * @returns {Promise<import("@fedify/fedify").Actor|null>}
624
+ */
625
+ export async function buildPersonActor(
626
+ ctx,
627
+ identifier,
628
+ collections,
629
+ defaultActorType = "Person",
630
+ ) {
631
+ const actorTypeMap = { Person, Service, Application, Organization, Group };
632
+ const profile = await getProfile(collections);
633
+ const keyPairs = await ctx.getActorKeyPairs(identifier);
634
+
635
+ const personOptions = {
636
+ id: ctx.getActorUri(identifier),
637
+ preferredUsername: identifier,
638
+ name: profile.name || identifier,
639
+ url: profile.url ? new URL(profile.url) : null,
640
+ inbox: ctx.getInboxUri(identifier),
641
+ outbox: ctx.getOutboxUri(identifier),
642
+ followers: ctx.getFollowersUri(identifier),
643
+ following: ctx.getFollowingUri(identifier),
644
+ liked: ctx.getLikedUri(identifier),
645
+ featured: ctx.getFeaturedUri(identifier),
646
+ featuredTags: ctx.getFeaturedTagsUri(identifier),
647
+ endpoints: new Endpoints({ sharedInbox: ctx.getInboxUri() }),
648
+ manuallyApprovesFollowers: profile.manuallyApprovesFollowers || false,
649
+ };
650
+
651
+ if (profile.summary) {
652
+ personOptions.summary = profile.summary;
653
+ }
654
+
655
+ if (profile.icon) {
656
+ personOptions.icon = new Image({
657
+ url: new URL(profile.icon),
658
+ mediaType: guessImageMediaType(profile.icon),
659
+ });
660
+ }
661
+
662
+ if (profile.image) {
663
+ personOptions.image = new Image({
664
+ url: new URL(profile.image),
665
+ mediaType: guessImageMediaType(profile.image),
666
+ });
667
+ }
668
+
669
+ if (keyPairs.length > 0) {
670
+ personOptions.publicKey = keyPairs[0].cryptographicKey;
671
+ personOptions.assertionMethods = keyPairs.map((k) => k.multikey);
672
+ }
673
+
674
+ if (profile.attachments?.length > 0) {
675
+ personOptions.attachments = profile.attachments.map(
676
+ (att) =>
677
+ new PropertyValue({
678
+ name: att.name,
679
+ value: formatAttachmentValue(att.value),
680
+ }),
681
+ );
682
+ }
683
+
684
+ if (profile.alsoKnownAs?.length > 0) {
685
+ personOptions.alsoKnownAs = profile.alsoKnownAs.map((u) => new URL(u));
686
+ }
687
+
688
+ if (profile.createdAt) {
689
+ personOptions.published = Temporal.Instant.from(profile.createdAt);
690
+ }
691
+
692
+ const profileActorType = profile.actorType || defaultActorType;
693
+ const ResolvedActorClass = actorTypeMap[profileActorType] || Person;
694
+
695
+ return new ResolvedActorClass(personOptions);
696
+ }
697
+
677
698
  /**
678
699
  * Import a PKCS#8 PEM private key using Web Crypto API.
679
700
  * Fedify's importPem only handles PKCS#1, but Node.js crypto generates PKCS#8.
package/locales/en.json CHANGED
@@ -50,6 +50,18 @@
50
50
  "authorizedFetchHint": "When enabled, only servers with valid HTTP Signatures can fetch your actor and collections. This improves privacy but may reduce compatibility with some clients.",
51
51
  "save": "Save profile",
52
52
  "saved": "Profile saved. Changes are now visible to the fediverse.",
53
+ "public": {
54
+ "followPrompt": "Follow me on the fediverse",
55
+ "copyHandle": "Copy handle",
56
+ "copied": "Copied!",
57
+ "pinnedPosts": "Pinned posts",
58
+ "recentPosts": "Recent posts",
59
+ "joinedDate": "Joined",
60
+ "posts": "Posts",
61
+ "followers": "Followers",
62
+ "following": "Following",
63
+ "viewOnSite": "View on site"
64
+ },
53
65
  "remote": {
54
66
  "follow": "Follow",
55
67
  "unfollow": "Unfollow",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rmdes/indiekit-endpoint-activitypub",
3
- "version": "1.1.17",
3
+ "version": "1.1.20",
4
4
  "description": "ActivityPub federation endpoint for Indiekit via Fedify. Adds full fediverse support: actor, inbox, outbox, followers, following, syndication, and Mastodon migration.",
5
5
  "keywords": [
6
6
  "indiekit",
@@ -0,0 +1,592 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>{{ profile.name or handle }} (@{{ handle }}@{{ domain }})</title>
7
+ <meta name="description" content="{{ profile.summary | striptags | truncate(160) if profile.summary else fullHandle }}">
8
+ <meta property="og:title" content="{{ profile.name or handle }}">
9
+ <meta property="og:description" content="{{ profile.summary | striptags | truncate(160) if profile.summary else fullHandle }}">
10
+ {% if profile.icon %}
11
+ <meta property="og:image" content="{{ profile.icon }}">
12
+ {% endif %}
13
+ <meta property="og:type" content="profile">
14
+ <meta property="og:url" content="{{ actorUrl }}">
15
+ <link rel="me" href="{{ siteUrl }}">
16
+ <link rel="alternate" type="application/activity+json" href="{{ actorUrl }}">
17
+ <style>
18
+ /* ================================================================
19
+ CSS Custom Properties — light/dark mode
20
+ ================================================================ */
21
+ :root {
22
+ --color-bg: #fff;
23
+ --color-surface: #f5f5f5;
24
+ --color-surface-raised: #fff;
25
+ --color-text: #1a1a1a;
26
+ --color-text-muted: #666;
27
+ --color-text-faint: #999;
28
+ --color-border: #e0e0e0;
29
+ --color-accent: #4f46e5;
30
+ --color-accent-text: #fff;
31
+ --color-purple: #7c3aed;
32
+ --color-green: #16a34a;
33
+ --color-yellow: #ca8a04;
34
+ --color-blue: #2563eb;
35
+ --radius-s: 6px;
36
+ --radius-m: 10px;
37
+ --radius-l: 16px;
38
+ --radius-full: 9999px;
39
+ --space-xs: 4px;
40
+ --space-s: 8px;
41
+ --space-m: 16px;
42
+ --space-l: 24px;
43
+ --space-xl: 32px;
44
+ --space-2xl: 48px;
45
+ --font-sans: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
46
+ --shadow-s: 0 1px 2px rgba(0,0,0,0.05);
47
+ --shadow-m: 0 2px 8px rgba(0,0,0,0.08);
48
+ }
49
+
50
+ @media (prefers-color-scheme: dark) {
51
+ :root {
52
+ --color-bg: #111;
53
+ --color-surface: #1a1a1a;
54
+ --color-surface-raised: #222;
55
+ --color-text: #e5e5e5;
56
+ --color-text-muted: #999;
57
+ --color-text-faint: #666;
58
+ --color-border: #333;
59
+ --color-accent: #818cf8;
60
+ --color-accent-text: #111;
61
+ --color-purple: #a78bfa;
62
+ --color-green: #4ade80;
63
+ --color-yellow: #facc15;
64
+ --color-blue: #60a5fa;
65
+ --shadow-s: 0 1px 2px rgba(0,0,0,0.2);
66
+ --shadow-m: 0 2px 8px rgba(0,0,0,0.3);
67
+ }
68
+ }
69
+
70
+ /* ================================================================
71
+ Base
72
+ ================================================================ */
73
+ *, *::before, *::after { box-sizing: border-box; }
74
+
75
+ body {
76
+ background: var(--color-bg);
77
+ color: var(--color-text);
78
+ font-family: var(--font-sans);
79
+ line-height: 1.5;
80
+ margin: 0;
81
+ -webkit-font-smoothing: antialiased;
82
+ }
83
+
84
+ a { color: var(--color-accent); text-decoration: none; }
85
+ a:hover { text-decoration: underline; }
86
+
87
+ .ap-pub {
88
+ margin: 0 auto;
89
+ max-width: 640px;
90
+ padding: 0 var(--space-m);
91
+ }
92
+
93
+ /* ================================================================
94
+ Header image
95
+ ================================================================ */
96
+ .ap-pub__header {
97
+ background: var(--color-surface);
98
+ height: 220px;
99
+ overflow: hidden;
100
+ position: relative;
101
+ }
102
+
103
+ .ap-pub__header img {
104
+ display: block;
105
+ height: 100%;
106
+ object-fit: cover;
107
+ width: 100%;
108
+ }
109
+
110
+ .ap-pub__header--empty {
111
+ background: linear-gradient(135deg, var(--color-accent), var(--color-purple));
112
+ height: 160px;
113
+ }
114
+
115
+ /* ================================================================
116
+ Identity — avatar, name, handle
117
+ ================================================================ */
118
+ .ap-pub__identity {
119
+ padding: 0 var(--space-m);
120
+ position: relative;
121
+ }
122
+
123
+ .ap-pub__avatar-wrap {
124
+ margin-top: -48px;
125
+ position: relative;
126
+ width: 96px;
127
+ }
128
+
129
+ .ap-pub__avatar {
130
+ background: var(--color-surface);
131
+ border: 4px solid var(--color-bg);
132
+ border-radius: var(--radius-full);
133
+ display: block;
134
+ height: 96px;
135
+ object-fit: cover;
136
+ width: 96px;
137
+ }
138
+
139
+ .ap-pub__avatar--placeholder {
140
+ align-items: center;
141
+ background: var(--color-surface);
142
+ border: 4px solid var(--color-bg);
143
+ border-radius: var(--radius-full);
144
+ color: var(--color-text-muted);
145
+ display: flex;
146
+ font-size: 2.5em;
147
+ font-weight: 700;
148
+ height: 96px;
149
+ justify-content: center;
150
+ width: 96px;
151
+ }
152
+
153
+ .ap-pub__name {
154
+ font-size: 1.5em;
155
+ font-weight: 700;
156
+ line-height: 1.2;
157
+ margin: var(--space-s) 0 var(--space-xs);
158
+ }
159
+
160
+ .ap-pub__handle {
161
+ color: var(--color-text-muted);
162
+ font-size: 0.95em;
163
+ margin-bottom: var(--space-m);
164
+ }
165
+
166
+ /* ================================================================
167
+ Bio
168
+ ================================================================ */
169
+ .ap-pub__bio {
170
+ line-height: 1.6;
171
+ margin-bottom: var(--space-l);
172
+ padding: 0 var(--space-m);
173
+ }
174
+
175
+ .ap-pub__bio a { color: var(--color-accent); }
176
+
177
+ .ap-pub__bio p { margin: 0 0 var(--space-s); }
178
+ .ap-pub__bio p:last-child { margin-bottom: 0; }
179
+
180
+ /* ================================================================
181
+ Profile fields
182
+ ================================================================ */
183
+ .ap-pub__fields {
184
+ border: 1px solid var(--color-border);
185
+ border-radius: var(--radius-m);
186
+ margin: 0 var(--space-m) var(--space-l);
187
+ overflow: hidden;
188
+ }
189
+
190
+ .ap-pub__field {
191
+ border-bottom: 1px solid var(--color-border);
192
+ display: grid;
193
+ grid-template-columns: 140px 1fr;
194
+ }
195
+
196
+ .ap-pub__field:last-child { border-bottom: 0; }
197
+
198
+ .ap-pub__field-name {
199
+ background: var(--color-surface);
200
+ color: var(--color-text-muted);
201
+ font-size: 0.85em;
202
+ font-weight: 600;
203
+ padding: var(--space-s) var(--space-m);
204
+ text-transform: uppercase;
205
+ letter-spacing: 0.03em;
206
+ }
207
+
208
+ .ap-pub__field-value {
209
+ font-size: 0.95em;
210
+ overflow: hidden;
211
+ padding: var(--space-s) var(--space-m);
212
+ text-overflow: ellipsis;
213
+ white-space: nowrap;
214
+ }
215
+
216
+ .ap-pub__field-value a { color: var(--color-accent); }
217
+
218
+ /* ================================================================
219
+ Stats bar
220
+ ================================================================ */
221
+ .ap-pub__stats {
222
+ border-bottom: 1px solid var(--color-border);
223
+ border-top: 1px solid var(--color-border);
224
+ display: flex;
225
+ margin: 0 var(--space-m) var(--space-l);
226
+ padding: var(--space-m) 0;
227
+ }
228
+
229
+ .ap-pub__stat {
230
+ flex: 1;
231
+ text-align: center;
232
+ }
233
+
234
+ .ap-pub__stat-value {
235
+ display: block;
236
+ font-size: 1.2em;
237
+ font-weight: 700;
238
+ }
239
+
240
+ .ap-pub__stat-label {
241
+ color: var(--color-text-muted);
242
+ display: block;
243
+ font-size: 0.8em;
244
+ text-transform: uppercase;
245
+ letter-spacing: 0.05em;
246
+ }
247
+
248
+ /* ================================================================
249
+ Follow prompt
250
+ ================================================================ */
251
+ .ap-pub__follow {
252
+ background: var(--color-surface);
253
+ border-radius: var(--radius-m);
254
+ margin: 0 var(--space-m) var(--space-l);
255
+ padding: var(--space-l);
256
+ text-align: center;
257
+ }
258
+
259
+ .ap-pub__follow-title {
260
+ font-size: 1em;
261
+ font-weight: 600;
262
+ margin: 0 0 var(--space-s);
263
+ }
264
+
265
+ .ap-pub__follow-handle {
266
+ background: var(--color-surface-raised);
267
+ border: 1px solid var(--color-border);
268
+ border-radius: var(--radius-s);
269
+ display: inline-flex;
270
+ align-items: center;
271
+ gap: var(--space-s);
272
+ padding: var(--space-s) var(--space-m);
273
+ }
274
+
275
+ .ap-pub__follow-text {
276
+ color: var(--color-text);
277
+ font-family: monospace;
278
+ font-size: 0.95em;
279
+ user-select: all;
280
+ }
281
+
282
+ .ap-pub__copy-btn {
283
+ background: var(--color-accent);
284
+ border: 0;
285
+ border-radius: var(--radius-s);
286
+ color: var(--color-accent-text);
287
+ cursor: pointer;
288
+ font-size: 0.8em;
289
+ font-weight: 600;
290
+ padding: var(--space-xs) var(--space-s);
291
+ transition: opacity 0.2s;
292
+ }
293
+
294
+ .ap-pub__copy-btn:hover { opacity: 0.85; }
295
+
296
+ /* ================================================================
297
+ Section headings
298
+ ================================================================ */
299
+ .ap-pub__section-title {
300
+ border-bottom: 1px solid var(--color-border);
301
+ font-size: 1.1em;
302
+ font-weight: 600;
303
+ margin: 0 var(--space-m) var(--space-m);
304
+ padding-bottom: var(--space-s);
305
+ }
306
+
307
+ /* ================================================================
308
+ Post cards (pinned + recent)
309
+ ================================================================ */
310
+ .ap-pub__posts {
311
+ display: flex;
312
+ flex-direction: column;
313
+ gap: var(--space-s);
314
+ margin: 0 var(--space-m) var(--space-l);
315
+ }
316
+
317
+ .ap-pub__post {
318
+ background: var(--color-surface-raised);
319
+ border: 1px solid var(--color-border);
320
+ border-left: 3px solid var(--color-border);
321
+ border-radius: var(--radius-s);
322
+ display: block;
323
+ padding: var(--space-m);
324
+ text-decoration: none;
325
+ transition: border-color 0.15s, box-shadow 0.15s;
326
+ }
327
+
328
+ .ap-pub__post:hover {
329
+ border-color: var(--color-accent);
330
+ box-shadow: var(--shadow-s);
331
+ text-decoration: none;
332
+ }
333
+
334
+ .ap-pub__post--article { border-left-color: var(--color-green); }
335
+ .ap-pub__post--note { border-left-color: var(--color-purple); }
336
+ .ap-pub__post--photo { border-left-color: var(--color-yellow); }
337
+ .ap-pub__post--bookmark { border-left-color: var(--color-blue); }
338
+
339
+ .ap-pub__post-meta {
340
+ align-items: center;
341
+ color: var(--color-text-muted);
342
+ display: flex;
343
+ font-size: 0.8em;
344
+ gap: var(--space-s);
345
+ margin-bottom: var(--space-xs);
346
+ }
347
+
348
+ .ap-pub__post-type {
349
+ background: var(--color-surface);
350
+ border-radius: var(--radius-s);
351
+ font-size: 0.85em;
352
+ font-weight: 600;
353
+ padding: 1px 6px;
354
+ text-transform: capitalize;
355
+ }
356
+
357
+ .ap-pub__post-title {
358
+ color: var(--color-text);
359
+ font-weight: 600;
360
+ line-height: 1.4;
361
+ }
362
+
363
+ .ap-pub__post-excerpt {
364
+ color: var(--color-text-muted);
365
+ font-size: 0.9em;
366
+ line-height: 1.5;
367
+ margin-top: var(--space-xs);
368
+ }
369
+
370
+ .ap-pub__pinned-label {
371
+ color: var(--color-yellow);
372
+ font-size: 0.75em;
373
+ font-weight: 600;
374
+ text-transform: uppercase;
375
+ letter-spacing: 0.05em;
376
+ }
377
+
378
+ /* ================================================================
379
+ Footer
380
+ ================================================================ */
381
+ .ap-pub__footer {
382
+ border-top: 1px solid var(--color-border);
383
+ color: var(--color-text-faint);
384
+ font-size: 0.85em;
385
+ margin: var(--space-xl) var(--space-m) 0;
386
+ padding: var(--space-l) 0;
387
+ text-align: center;
388
+ }
389
+
390
+ .ap-pub__footer a { color: var(--color-text-muted); }
391
+
392
+ /* ================================================================
393
+ Empty state
394
+ ================================================================ */
395
+ .ap-pub__empty {
396
+ color: var(--color-text-muted);
397
+ font-style: italic;
398
+ padding: var(--space-m) 0;
399
+ text-align: center;
400
+ }
401
+
402
+ /* ================================================================
403
+ Responsive
404
+ ================================================================ */
405
+ @media (max-width: 480px) {
406
+ .ap-pub__header { height: 160px; }
407
+ .ap-pub__header--empty { height: 120px; }
408
+
409
+ .ap-pub__field {
410
+ grid-template-columns: 1fr;
411
+ }
412
+
413
+ .ap-pub__field-name {
414
+ border-bottom: 0;
415
+ padding-bottom: var(--space-xs);
416
+ }
417
+
418
+ .ap-pub__field-value {
419
+ padding-top: 0;
420
+ }
421
+
422
+ .ap-pub__stats { flex-wrap: wrap; }
423
+
424
+ .ap-pub__stat {
425
+ flex: 0 0 50%;
426
+ margin-bottom: var(--space-s);
427
+ }
428
+
429
+ .ap-pub__follow-handle {
430
+ flex-direction: column;
431
+ }
432
+ }
433
+ </style>
434
+ </head>
435
+ <body>
436
+ {# ---- Header image ---- #}
437
+ {% if profile.image %}
438
+ <div class="ap-pub__header">
439
+ <img src="{{ profile.image }}" alt="">
440
+ </div>
441
+ {% else %}
442
+ <div class="ap-pub__header ap-pub__header--empty"></div>
443
+ {% endif %}
444
+
445
+ <div class="ap-pub">
446
+ {# ---- Avatar + identity ---- #}
447
+ <div class="ap-pub__identity">
448
+ <div class="ap-pub__avatar-wrap">
449
+ {% if profile.icon %}
450
+ <img src="{{ profile.icon }}" alt="{{ profile.name or handle }}" class="ap-pub__avatar">
451
+ {% else %}
452
+ <div class="ap-pub__avatar--placeholder">{{ (profile.name or handle)[0] | upper }}</div>
453
+ {% endif %}
454
+ </div>
455
+ <h1 class="ap-pub__name">{{ profile.name or handle }}</h1>
456
+ <div class="ap-pub__handle">{{ fullHandle }}</div>
457
+ </div>
458
+
459
+ {# ---- Bio ---- #}
460
+ {% if profile.summary %}
461
+ <div class="ap-pub__bio">{{ profile.summary | safe }}</div>
462
+ {% endif %}
463
+
464
+ {# ---- Profile fields (attachments) ---- #}
465
+ {% if profile.attachments and profile.attachments.length > 0 %}
466
+ <dl class="ap-pub__fields">
467
+ {% for field in profile.attachments %}
468
+ <div class="ap-pub__field">
469
+ <dt class="ap-pub__field-name">{{ field.name }}</dt>
470
+ <dd class="ap-pub__field-value">
471
+ {% if field.value and (field.value.startsWith("http://") or field.value.startsWith("https://")) %}
472
+ <a href="{{ field.value }}" rel="noopener nofollow" target="_blank">{{ field.value | replace("https://", "") | replace("http://", "") }}</a>
473
+ {% else %}
474
+ {{ field.value }}
475
+ {% endif %}
476
+ </dd>
477
+ </div>
478
+ {% endfor %}
479
+ </dl>
480
+ {% endif %}
481
+
482
+ {# ---- Stats bar ---- #}
483
+ <div class="ap-pub__stats">
484
+ <div class="ap-pub__stat">
485
+ <span class="ap-pub__stat-value">{{ postCount }}</span>
486
+ <span class="ap-pub__stat-label">Posts</span>
487
+ </div>
488
+ <div class="ap-pub__stat">
489
+ <span class="ap-pub__stat-value">{{ followingCount }}</span>
490
+ <span class="ap-pub__stat-label">Following</span>
491
+ </div>
492
+ <div class="ap-pub__stat">
493
+ <span class="ap-pub__stat-value">{{ followerCount }}</span>
494
+ <span class="ap-pub__stat-label">Followers</span>
495
+ </div>
496
+ {% if profile.createdAt %}
497
+ <div class="ap-pub__stat">
498
+ <span class="ap-pub__stat-value" id="joined-date">—</span>
499
+ <span class="ap-pub__stat-label">Joined</span>
500
+ </div>
501
+ {% endif %}
502
+ </div>
503
+
504
+ {# ---- Follow prompt ---- #}
505
+ <div class="ap-pub__follow">
506
+ <p class="ap-pub__follow-title">Follow me on the fediverse</p>
507
+ <div class="ap-pub__follow-handle">
508
+ <span class="ap-pub__follow-text" id="fedi-handle">{{ fullHandle }}</span>
509
+ <button class="ap-pub__copy-btn" id="copy-btn" type="button">Copy handle</button>
510
+ </div>
511
+ </div>
512
+
513
+ {# ---- Pinned posts ---- #}
514
+ {% if pinned.length > 0 %}
515
+ <h2 class="ap-pub__section-title">Pinned posts</h2>
516
+ <div class="ap-pub__posts">
517
+ {% for post in pinned %}
518
+ <a href="{{ post.url }}" class="ap-pub__post ap-pub__post--{{ post.type }}">
519
+ <div class="ap-pub__post-meta">
520
+ <span class="ap-pub__pinned-label">Pinned</span>
521
+ <span class="ap-pub__post-type">{{ post.type }}</span>
522
+ {% if post.published %}
523
+ <time datetime="{{ post.published }}">{{ post.published | truncate(10, true, "") }}</time>
524
+ {% endif %}
525
+ </div>
526
+ <div class="ap-pub__post-title">{{ post.title }}</div>
527
+ </a>
528
+ {% endfor %}
529
+ </div>
530
+ {% endif %}
531
+
532
+ {# ---- Recent posts ---- #}
533
+ {% if recentPosts.length > 0 %}
534
+ <h2 class="ap-pub__section-title">Recent posts</h2>
535
+ <div class="ap-pub__posts">
536
+ {% for post in recentPosts %}
537
+ {% set postType = post["post-type"] or "note" %}
538
+ <a href="{{ post.url }}" class="ap-pub__post ap-pub__post--{{ postType }}">
539
+ <div class="ap-pub__post-meta">
540
+ <span class="ap-pub__post-type">{{ postType }}</span>
541
+ {% if post.published %}
542
+ <time datetime="{{ post.published }}">{{ post.published | truncate(10, true, "") }}</time>
543
+ {% endif %}
544
+ </div>
545
+ {% if post.name %}
546
+ <div class="ap-pub__post-title">{{ post.name }}</div>
547
+ {% endif %}
548
+ {% if post.content and post.content.text %}
549
+ <div class="ap-pub__post-excerpt">{{ post.content.text | truncate(150) }}</div>
550
+ {% endif %}
551
+ </a>
552
+ {% endfor %}
553
+ </div>
554
+ {% endif %}
555
+
556
+ {# ---- Empty state ---- #}
557
+ {% if pinned.length === 0 and recentPosts.length === 0 %}
558
+ <p class="ap-pub__empty">No posts yet.</p>
559
+ {% endif %}
560
+
561
+ {# ---- Footer ---- #}
562
+ <footer class="ap-pub__footer">
563
+ <a href="{{ siteUrl }}">{{ domain }}</a>
564
+ </footer>
565
+ </div>
566
+
567
+ <script>
568
+ // Copy handle to clipboard
569
+ document.getElementById("copy-btn").addEventListener("click", function() {
570
+ var handle = document.getElementById("fedi-handle").textContent;
571
+ navigator.clipboard.writeText(handle).then(function() {
572
+ var btn = document.getElementById("copy-btn");
573
+ btn.textContent = "Copied!";
574
+ setTimeout(function() { btn.textContent = "Copy handle"; }, 2000);
575
+ });
576
+ });
577
+
578
+ // Format joined date
579
+ {% if profile.createdAt %}
580
+ (function() {
581
+ var el = document.getElementById("joined-date");
582
+ if (el) {
583
+ try {
584
+ var d = new Date("{{ profile.createdAt }}");
585
+ el.textContent = d.toLocaleDateString(undefined, { month: "short", year: "numeric" });
586
+ } catch(e) { el.textContent = "—"; }
587
+ }
588
+ })();
589
+ {% endif %}
590
+ </script>
591
+ </body>
592
+ </html>