@commonpub/layer 0.3.26 → 0.3.28

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
@@ -65,7 +65,7 @@ API routes for all CommonPub features, auth middleware, federation endpoints, an
65
65
 
66
66
  ### Theme
67
67
 
68
- CSS custom properties with 4 built-in themes (base, deepwood, hackbuild, deveco). Override with your own `theme.css`.
68
+ CSS custom properties with 3 built-in themes (base, dark, generics). Consumer apps override with their own `theme.css`.
69
69
 
70
70
  ## Customization
71
71
 
@@ -17,6 +17,13 @@ const props = defineProps<{
17
17
 
18
18
  const emit = defineEmits<{ vote: [] }>();
19
19
 
20
+ /** Strip HTML tags for safe plain-text preview */
21
+ function stripHtml(html: string): string {
22
+ return html.replace(/<[^>]*>/g, '').trim();
23
+ }
24
+
25
+ const previewText = computed(() => stripHtml(props.body));
26
+
20
27
  const typeBadgeClass = computed((): string => {
21
28
  const map: Record<string, string> = {
22
29
  discussion: 'cpub-feed-badge-accent',
@@ -55,7 +62,7 @@ const formattedDate = computed((): string => {
55
62
 
56
63
  <h3 class="cpub-feed-item-title">{{ title }}</h3>
57
64
 
58
- <p class="cpub-feed-item-preview">{{ body }}</p>
65
+ <p class="cpub-feed-item-preview">{{ previewText }}</p>
59
66
 
60
67
  <div class="cpub-feed-item-meta">
61
68
  <div class="cpub-feed-item-author">
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Component tests for FederatedContentCard.
3
+ *
4
+ * Tests rendering of federated content from CommonPub and non-CommonPub sources,
5
+ * computed properties (typeLabel, actorHandle, timeAgo), event emission, and
6
+ * conditional rendering (avatar, cover image, tags, title link).
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { render, screen, fireEvent } from '@testing-library/vue';
10
+ import { defineComponent, h } from 'vue';
11
+ import FederatedContentCard from '../FederatedContentCard.vue';
12
+
13
+ // Stub NuxtLink as a plain <a> tag
14
+ const NuxtLink = defineComponent({
15
+ name: 'NuxtLink',
16
+ props: { to: String },
17
+ setup(props, { slots }) {
18
+ return () => h('a', { href: props.to }, slots.default?.());
19
+ },
20
+ });
21
+
22
+ const stubs = { NuxtLink };
23
+
24
+ function makeContent(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ id: 'fed-1',
27
+ objectUri: 'https://remote.example.com/content/test',
28
+ apType: 'Article',
29
+ title: 'LED Cube Build',
30
+ content: '<p>Build a 4x4x4 LED cube</p>',
31
+ summary: '<p>A <strong>complete</strong> LED cube tutorial</p>',
32
+ url: 'https://remote.example.com/project/led-cube',
33
+ coverImageUrl: null,
34
+ tags: [],
35
+ attachments: [],
36
+ inReplyTo: null,
37
+ cpubType: 'project',
38
+ cpubMetadata: null,
39
+ cpubBlocks: null,
40
+ localLikeCount: 5,
41
+ localCommentCount: 2,
42
+ localViewCount: 100,
43
+ publishedAt: new Date(Date.now() - 3600000).toISOString(), // 1 hour ago
44
+ receivedAt: new Date().toISOString(),
45
+ originDomain: 'remote.example.com',
46
+ actor: {
47
+ actorUri: 'https://remote.example.com/users/alice',
48
+ preferredUsername: 'alice',
49
+ displayName: 'Alice Builder',
50
+ avatarUrl: 'https://remote.example.com/avatars/alice.png',
51
+ instanceDomain: 'remote.example.com',
52
+ },
53
+ ...overrides,
54
+ };
55
+ }
56
+
57
+ describe('FederatedContentCard', () => {
58
+ // --- Basic rendering ---
59
+
60
+ it('renders title', () => {
61
+ render(FederatedContentCard, {
62
+ props: { content: makeContent() },
63
+ global: { stubs },
64
+ });
65
+ expect(screen.getByText('LED Cube Build')).toBeInTheDocument();
66
+ });
67
+
68
+ it('renders actor name and handle', () => {
69
+ render(FederatedContentCard, {
70
+ props: { content: makeContent() },
71
+ global: { stubs },
72
+ });
73
+ expect(screen.getByText('Alice Builder')).toBeInTheDocument();
74
+ expect(screen.getByText('@alice@remote.example.com')).toBeInTheDocument();
75
+ });
76
+
77
+ it('renders origin domain badge', () => {
78
+ render(FederatedContentCard, {
79
+ props: { content: makeContent() },
80
+ global: { stubs },
81
+ });
82
+ expect(screen.getByText('remote.example.com')).toBeInTheDocument();
83
+ });
84
+
85
+ it('strips HTML from summary', () => {
86
+ render(FederatedContentCard, {
87
+ props: { content: makeContent() },
88
+ global: { stubs },
89
+ });
90
+ const summary = screen.getByText('A complete LED cube tutorial');
91
+ expect(summary).toBeInTheDocument();
92
+ // Should NOT contain HTML tags
93
+ expect(summary.innerHTML).not.toContain('<strong>');
94
+ expect(summary.innerHTML).not.toContain('<p>');
95
+ });
96
+
97
+ // --- Type label computed ---
98
+
99
+ it('shows cpubType as type badge when present', () => {
100
+ render(FederatedContentCard, {
101
+ props: { content: makeContent({ cpubType: 'project' }) },
102
+ global: { stubs },
103
+ });
104
+ expect(screen.getByText('project')).toBeInTheDocument();
105
+ });
106
+
107
+ it('shows "article" for AP Article without cpubType', () => {
108
+ render(FederatedContentCard, {
109
+ props: { content: makeContent({ cpubType: null, apType: 'Article' }) },
110
+ global: { stubs },
111
+ });
112
+ expect(screen.getByText('article')).toBeInTheDocument();
113
+ });
114
+
115
+ it('shows "post" for AP Note without cpubType', () => {
116
+ render(FederatedContentCard, {
117
+ props: { content: makeContent({ cpubType: null, apType: 'Note' }) },
118
+ global: { stubs },
119
+ });
120
+ expect(screen.getByText('post')).toBeInTheDocument();
121
+ });
122
+
123
+ // --- Avatar rendering ---
124
+
125
+ it('renders avatar image when actor has avatarUrl', () => {
126
+ render(FederatedContentCard, {
127
+ props: { content: makeContent() },
128
+ global: { stubs },
129
+ });
130
+ const img = screen.getByAltText('Alice Builder avatar');
131
+ expect(img).toBeInTheDocument();
132
+ expect(img).toHaveAttribute('src', 'https://remote.example.com/avatars/alice.png');
133
+ });
134
+
135
+ it('renders placeholder when actor has no avatarUrl', () => {
136
+ const { container } = render(FederatedContentCard, {
137
+ props: {
138
+ content: makeContent({
139
+ actor: {
140
+ actorUri: 'https://remote.example.com/users/bob',
141
+ preferredUsername: 'bob',
142
+ displayName: 'Bob',
143
+ avatarUrl: null,
144
+ instanceDomain: 'remote.example.com',
145
+ },
146
+ }),
147
+ },
148
+ global: { stubs },
149
+ });
150
+ const placeholder = container.querySelector('.cpub-fed-card__avatar--placeholder');
151
+ expect(placeholder).toBeInTheDocument();
152
+ expect(placeholder?.textContent).toBe('B');
153
+ });
154
+
155
+ // --- Cover image ---
156
+
157
+ it('renders cover image through proxy when coverImageUrl present', () => {
158
+ const { container } = render(FederatedContentCard, {
159
+ props: {
160
+ content: makeContent({
161
+ coverImageUrl: 'https://remote.example.com/img/cover.jpg',
162
+ }),
163
+ },
164
+ global: { stubs },
165
+ });
166
+ const cover = container.querySelector('.cpub-fed-card__cover img');
167
+ expect(cover).toBeInTheDocument();
168
+ expect(cover?.getAttribute('src')).toContain('/api/image-proxy');
169
+ expect(cover?.getAttribute('src')).toContain(encodeURIComponent('https://remote.example.com/img/cover.jpg'));
170
+ });
171
+
172
+ it('does not render cover image when coverImageUrl is null', () => {
173
+ const { container } = render(FederatedContentCard, {
174
+ props: { content: makeContent({ coverImageUrl: null }) },
175
+ global: { stubs },
176
+ });
177
+ expect(container.querySelector('.cpub-fed-card__cover')).not.toBeInTheDocument();
178
+ });
179
+
180
+ // --- Tags ---
181
+
182
+ it('renders tags when present', () => {
183
+ render(FederatedContentCard, {
184
+ props: {
185
+ content: makeContent({
186
+ tags: [
187
+ { type: 'Hashtag', name: '#electronics' },
188
+ { type: 'Hashtag', name: '#led' },
189
+ ],
190
+ }),
191
+ },
192
+ global: { stubs },
193
+ });
194
+ expect(screen.getByText('#electronics')).toBeInTheDocument();
195
+ expect(screen.getByText('#led')).toBeInTheDocument();
196
+ });
197
+
198
+ it('limits tags to 5', () => {
199
+ const tags = Array.from({ length: 8 }, (_, i) => ({
200
+ type: 'Hashtag',
201
+ name: `#tag${i}`,
202
+ }));
203
+ const { container } = render(FederatedContentCard, {
204
+ props: { content: makeContent({ tags }) },
205
+ global: { stubs },
206
+ });
207
+ const tagElements = container.querySelectorAll('.cpub-fed-card__tag');
208
+ expect(tagElements.length).toBe(5);
209
+ });
210
+
211
+ it('hides tags section when empty', () => {
212
+ const { container } = render(FederatedContentCard, {
213
+ props: { content: makeContent({ tags: [] }) },
214
+ global: { stubs },
215
+ });
216
+ expect(container.querySelector('.cpub-fed-card__tags')).not.toBeInTheDocument();
217
+ });
218
+
219
+ // --- Like count ---
220
+
221
+ it('shows like count when > 0', () => {
222
+ render(FederatedContentCard, {
223
+ props: { content: makeContent({ localLikeCount: 5 }) },
224
+ global: { stubs },
225
+ });
226
+ expect(screen.getByLabelText('Like this project')).toHaveTextContent('5 Like');
227
+ });
228
+
229
+ it('hides like count when 0', () => {
230
+ render(FederatedContentCard, {
231
+ props: { content: makeContent({ localLikeCount: 0 }) },
232
+ global: { stubs },
233
+ });
234
+ expect(screen.getByLabelText('Like this project')).toHaveTextContent('Like');
235
+ expect(screen.getByLabelText('Like this project').textContent?.trim()).toBe('Like');
236
+ });
237
+
238
+ // --- Events ---
239
+
240
+ it('emits like event with content id', async () => {
241
+ const { emitted } = render(FederatedContentCard, {
242
+ props: { content: makeContent() },
243
+ global: { stubs },
244
+ });
245
+ await fireEvent.click(screen.getByLabelText('Like this project'));
246
+ expect(emitted().like).toBeTruthy();
247
+ expect(emitted().like[0]).toEqual(['fed-1']);
248
+ });
249
+
250
+ it('emits boost event with content id', async () => {
251
+ const { emitted } = render(FederatedContentCard, {
252
+ props: { content: makeContent() },
253
+ global: { stubs },
254
+ });
255
+ await fireEvent.click(screen.getByLabelText('Boost this project'));
256
+ expect(emitted().boost).toBeTruthy();
257
+ expect(emitted().boost[0]).toEqual(['fed-1']);
258
+ });
259
+
260
+ // --- Title link ---
261
+
262
+ it('renders title as link when url is present', () => {
263
+ render(FederatedContentCard, {
264
+ props: { content: makeContent() },
265
+ global: { stubs },
266
+ });
267
+ const link = screen.getByText('LED Cube Build').closest('a');
268
+ expect(link).toHaveAttribute('href', 'https://remote.example.com/project/led-cube');
269
+ expect(link).toHaveAttribute('target', '_blank');
270
+ });
271
+
272
+ it('renders title as plain text when url is null', () => {
273
+ render(FederatedContentCard, {
274
+ props: { content: makeContent({ url: null }) },
275
+ global: { stubs },
276
+ });
277
+ const title = screen.getByText('LED Cube Build');
278
+ expect(title.tagName).toBe('SPAN');
279
+ });
280
+
281
+ // --- View Original link ---
282
+
283
+ it('shows View Original link when url present', () => {
284
+ render(FederatedContentCard, {
285
+ props: { content: makeContent() },
286
+ global: { stubs },
287
+ });
288
+ const link = screen.getByText('View Original');
289
+ expect(link).toHaveAttribute('href', 'https://remote.example.com/project/led-cube');
290
+ expect(link).toHaveAttribute('rel', 'noopener');
291
+ });
292
+
293
+ it('hides View Original when no url', () => {
294
+ render(FederatedContentCard, {
295
+ props: { content: makeContent({ url: null }) },
296
+ global: { stubs },
297
+ });
298
+ expect(screen.queryByText('View Original')).not.toBeInTheDocument();
299
+ });
300
+
301
+ // --- Time ago ---
302
+
303
+ it('shows relative time for recent content', () => {
304
+ const { container } = render(FederatedContentCard, {
305
+ props: {
306
+ content: makeContent({
307
+ publishedAt: new Date(Date.now() - 30 * 60000).toISOString(),
308
+ }),
309
+ },
310
+ global: { stubs },
311
+ });
312
+ const time = container.querySelector('.cpub-fed-card__time');
313
+ expect(time?.textContent).toBe('30m');
314
+ });
315
+
316
+ it('shows hours for content from today', () => {
317
+ const { container } = render(FederatedContentCard, {
318
+ props: {
319
+ content: makeContent({
320
+ publishedAt: new Date(Date.now() - 5 * 3600000).toISOString(),
321
+ }),
322
+ },
323
+ global: { stubs },
324
+ });
325
+ const time = container.querySelector('.cpub-fed-card__time');
326
+ expect(time?.textContent).toBe('5h');
327
+ });
328
+
329
+ // --- Fallback values ---
330
+
331
+ it('shows Unknown when actor is null', () => {
332
+ render(FederatedContentCard, {
333
+ props: { content: makeContent({ actor: null }) },
334
+ global: { stubs },
335
+ });
336
+ // Both actorName and actorHandle render "Unknown" fallback
337
+ const unknowns = screen.getAllByText('Unknown');
338
+ expect(unknowns.length).toBeGreaterThanOrEqual(1);
339
+ });
340
+ });
@@ -5,8 +5,15 @@ const props = defineProps<{
5
5
  posts: HubPostViewModel[]
6
6
  }>();
7
7
 
8
+ function stripHtml(html: string): string {
9
+ return html.replace(/<[^>]*>/g, '').trim();
10
+ }
11
+
8
12
  const discussionPosts = computed(() => {
9
- return props.posts.filter((p) => p.type === 'text' || p.type === 'link' || p.type === 'discussion' || p.type === 'question');
13
+ return props.posts.filter((p) =>
14
+ (p.type === 'text' || p.type === 'link' || p.type === 'discussion' || p.type === 'question')
15
+ && !p.sharedContent,
16
+ );
10
17
  });
11
18
  </script>
12
19
 
@@ -18,7 +25,7 @@ const discussionPosts = computed(() => {
18
25
  <template v-for="post in discussionPosts" :key="post.id">
19
26
  <NuxtLink v-if="post.linkTo" :to="post.linkTo" class="cpub-feed-link">
20
27
  <DiscussionItem
21
- :title="post.content?.slice(0, 80) || 'Untitled'"
28
+ :title="stripHtml(post.content || '').slice(0, 80) || 'Untitled'"
22
29
  :author="post.author.name"
23
30
  :reply-count="post.replyCount"
24
31
  :vote-count="post.likeCount"
@@ -26,7 +33,7 @@ const discussionPosts = computed(() => {
26
33
  </NuxtLink>
27
34
  <div v-else>
28
35
  <DiscussionItem
29
- :title="post.content?.slice(0, 80) || 'Untitled'"
36
+ :title="stripHtml(post.content || '').slice(0, 80) || 'Untitled'"
30
37
  :author="post.author.name"
31
38
  :reply-count="post.replyCount"
32
39
  :vote-count="post.likeCount"
@@ -9,6 +9,11 @@ const props = defineProps<{
9
9
 
10
10
  const emit = defineEmits<{ 'post-vote': [postId: string] }>();
11
11
 
12
+ /** Strip HTML tags for plain-text display in feed items */
13
+ function stripHtml(html: string): string {
14
+ return html.replace(/<[^>]*>/g, '').trim();
15
+ }
16
+
12
17
  const feedFilter = ref('all');
13
18
 
14
19
  const feedFilters = [
@@ -30,8 +35,8 @@ const filteredPosts = computed(() => {
30
35
  <AnnouncementBand
31
36
  v-for="post in filteredPosts.filter(p => p.isPinned && p.type === 'announcement')"
32
37
  :key="`ann-${post.id}`"
33
- :title="post.content?.slice(0, 80) || 'Announcement'"
34
- :body="post.content || ''"
38
+ :title="stripHtml(post.content || '').slice(0, 80) || 'Announcement'"
39
+ :body="stripHtml(post.content || '')"
35
40
  :author="post.author.name"
36
41
  :created-at="new Date(post.createdAt)"
37
42
  :pinned="true"
@@ -105,7 +110,7 @@ const filteredPosts = computed(() => {
105
110
  <NuxtLink v-if="post.linkTo" :to="post.linkTo" class="cpub-feed-link">
106
111
  <FeedItem
107
112
  :type="(post.type as 'discussion' | 'question' | 'showcase' | 'announcement') || 'discussion'"
108
- :title="post.content?.slice(0, 80) || ''"
113
+ :title="stripHtml(post.content || '').slice(0, 80) || ''"
109
114
  :author="post.author.name"
110
115
  :author-avatar="post.author.avatarUrl ?? undefined"
111
116
  :author-handle="post.author.handle ?? undefined"
@@ -123,7 +128,7 @@ const filteredPosts = computed(() => {
123
128
  <div v-else>
124
129
  <FeedItem
125
130
  :type="(post.type as 'discussion' | 'question' | 'showcase' | 'announcement') || 'discussion'"
126
- :title="post.content?.slice(0, 80) || ''"
131
+ :title="stripHtml(post.content || '').slice(0, 80) || ''"
127
132
  :author="post.author.name"
128
133
  :author-avatar="post.author.avatarUrl ?? undefined"
129
134
  :author-handle="post.author.handle ?? undefined"
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Unit tests for useMirrorContent composable.
3
+ *
4
+ * Tests the contentType resolution logic which determines how federated
5
+ * content is displayed — critical for distinguishing CommonPub vs non-CommonPub
6
+ * content types.
7
+ */
8
+ import { describe, it, expect } from 'vitest';
9
+ import { ref, nextTick } from 'vue';
10
+ import { useMirrorContent } from '../useMirrorContent';
11
+
12
+ function makeFedContent(overrides: Record<string, unknown> = {}) {
13
+ return {
14
+ id: 'fed-1',
15
+ objectUri: 'https://remote.example.com/content/test',
16
+ apType: 'Article',
17
+ cpubType: null,
18
+ title: 'Test Content',
19
+ content: '<p>Hello world</p>',
20
+ summary: 'A test',
21
+ url: 'https://remote.example.com/article/test',
22
+ coverImageUrl: null,
23
+ tags: [],
24
+ attachments: [],
25
+ cpubMetadata: null,
26
+ cpubBlocks: null,
27
+ localLikeCount: 0,
28
+ localCommentCount: 0,
29
+ localViewCount: 0,
30
+ publishedAt: '2026-03-20T10:00:00Z',
31
+ receivedAt: '2026-03-20T11:00:00Z',
32
+ originDomain: 'remote.example.com',
33
+ actor: {
34
+ actorUri: 'https://remote.example.com/users/alice',
35
+ preferredUsername: 'alice',
36
+ displayName: 'Alice',
37
+ avatarUrl: null,
38
+ instanceDomain: 'remote.example.com',
39
+ },
40
+ ...overrides,
41
+ };
42
+ }
43
+
44
+ describe('useMirrorContent', () => {
45
+ // --- contentType resolution ---
46
+
47
+ describe('contentType', () => {
48
+ it('returns cpubType when present (CommonPub project)', () => {
49
+ const fedContent = ref(makeFedContent({ cpubType: 'project' }));
50
+ const { contentType } = useMirrorContent(fedContent);
51
+ expect(contentType.value).toBe('project');
52
+ });
53
+
54
+ it('returns cpubType when present (CommonPub article)', () => {
55
+ const fedContent = ref(makeFedContent({ cpubType: 'article' }));
56
+ const { contentType } = useMirrorContent(fedContent);
57
+ expect(contentType.value).toBe('article');
58
+ });
59
+
60
+ it('returns cpubType when present (CommonPub blog)', () => {
61
+ const fedContent = ref(makeFedContent({ cpubType: 'blog' }));
62
+ const { contentType } = useMirrorContent(fedContent);
63
+ expect(contentType.value).toBe('blog');
64
+ });
65
+
66
+ it('returns cpubType when present (CommonPub explainer)', () => {
67
+ const fedContent = ref(makeFedContent({ cpubType: 'explainer' }));
68
+ const { contentType } = useMirrorContent(fedContent);
69
+ expect(contentType.value).toBe('explainer');
70
+ });
71
+
72
+ it('falls back to apType lowercase for non-CommonPub Article', () => {
73
+ const fedContent = ref(makeFedContent({ cpubType: null, apType: 'Article' }));
74
+ const { contentType } = useMirrorContent(fedContent);
75
+ expect(contentType.value).toBe('article');
76
+ });
77
+
78
+ it('falls back to apType lowercase for Note', () => {
79
+ const fedContent = ref(makeFedContent({ cpubType: null, apType: 'Note' }));
80
+ const { contentType } = useMirrorContent(fedContent);
81
+ expect(contentType.value).toBe('note');
82
+ });
83
+
84
+ it('falls back to "article" when both cpubType and apType are null', () => {
85
+ const fedContent = ref(makeFedContent({ cpubType: null, apType: null }));
86
+ const { contentType } = useMirrorContent(fedContent);
87
+ expect(contentType.value).toBe('article');
88
+ });
89
+
90
+ it('prefers cpubType over apType', () => {
91
+ const fedContent = ref(makeFedContent({ cpubType: 'project', apType: 'Article' }));
92
+ const { contentType } = useMirrorContent(fedContent);
93
+ expect(contentType.value).toBe('project');
94
+ });
95
+ });
96
+
97
+ // --- transformedContent ---
98
+
99
+ describe('transformedContent', () => {
100
+ it('returns null when fedContent is null', () => {
101
+ const fedContent = ref(null);
102
+ const { transformedContent } = useMirrorContent(fedContent);
103
+ expect(transformedContent.value).toBeNull();
104
+ });
105
+
106
+ it('maps title correctly', () => {
107
+ const fedContent = ref(makeFedContent({ title: 'LED Cube Build' }));
108
+ const { transformedContent } = useMirrorContent(fedContent);
109
+ expect(transformedContent.value?.title).toBe('LED Cube Build');
110
+ });
111
+
112
+ it('uses "Untitled" when title is null', () => {
113
+ const fedContent = ref(makeFedContent({ title: null }));
114
+ const { transformedContent } = useMirrorContent(fedContent);
115
+ expect(transformedContent.value?.title).toBe('Untitled');
116
+ });
117
+
118
+ it('preserves cpubBlocks when present (CommonPub-to-CommonPub)', () => {
119
+ const blocks = [['paragraph', { text: 'Hello' }], ['heading', { level: 2, text: 'World' }]];
120
+ const fedContent = ref(makeFedContent({ cpubBlocks: blocks }));
121
+ const { transformedContent } = useMirrorContent(fedContent);
122
+ expect(transformedContent.value?.content).toEqual(blocks);
123
+ });
124
+
125
+ it('wraps HTML content as paragraph block (non-CommonPub)', () => {
126
+ const fedContent = ref(makeFedContent({
127
+ cpubBlocks: null,
128
+ content: '<p>Hello from Mastodon</p>',
129
+ }));
130
+ const { transformedContent } = useMirrorContent(fedContent);
131
+ expect(transformedContent.value?.content).toEqual([
132
+ ['paragraph', { html: '<p>Hello from Mastodon</p>' }],
133
+ ]);
134
+ });
135
+
136
+ it('extracts metadata from cpubMetadata', () => {
137
+ const fedContent = ref(makeFedContent({
138
+ cpubType: 'project',
139
+ cpubMetadata: { difficulty: 'intermediate', buildTime: '4h', estimatedCost: '$50' },
140
+ }));
141
+ const { transformedContent } = useMirrorContent(fedContent);
142
+ expect(transformedContent.value?.difficulty).toBe('intermediate');
143
+ expect(transformedContent.value?.buildTime).toBe('4h');
144
+ expect(transformedContent.value?.estimatedCost).toBe('$50');
145
+ });
146
+
147
+ it('maps tags to expected format', () => {
148
+ const fedContent = ref(makeFedContent({
149
+ tags: [
150
+ { type: 'Hashtag', name: '#electronics' },
151
+ { type: 'Hashtag', name: '#led' },
152
+ ],
153
+ }));
154
+ const { transformedContent } = useMirrorContent(fedContent);
155
+ expect(transformedContent.value?.tags).toHaveLength(2);
156
+ expect(transformedContent.value?.tags[0]?.name).toBe('#electronics');
157
+ });
158
+
159
+ it('maps actor to author format', () => {
160
+ const fedContent = ref(makeFedContent({
161
+ actor: {
162
+ actorUri: 'https://remote.example.com/users/bob',
163
+ preferredUsername: 'bob',
164
+ displayName: 'Bob Builder',
165
+ avatarUrl: 'https://remote.example.com/avatar.png',
166
+ instanceDomain: 'remote.example.com',
167
+ followerCount: 42,
168
+ },
169
+ }));
170
+ const { transformedContent } = useMirrorContent(fedContent);
171
+ expect(transformedContent.value?.author.username).toBe('bob');
172
+ expect(transformedContent.value?.author.displayName).toBe('Bob Builder');
173
+ expect(transformedContent.value?.author.avatarUrl).toBe('https://remote.example.com/avatar.png');
174
+ });
175
+ });
176
+
177
+ // --- originDomain ---
178
+
179
+ describe('originDomain', () => {
180
+ it('extracts origin domain', () => {
181
+ const fedContent = ref(makeFedContent({ originDomain: 'mastodon.social' }));
182
+ const { originDomain } = useMirrorContent(fedContent);
183
+ expect(originDomain.value).toBe('mastodon.social');
184
+ });
185
+
186
+ it('falls back to "unknown" when null', () => {
187
+ const fedContent = ref(makeFedContent({ originDomain: null }));
188
+ const { originDomain } = useMirrorContent(fedContent);
189
+ expect(originDomain.value).toBe('unknown');
190
+ });
191
+ });
192
+
193
+ // --- authorHandle ---
194
+
195
+ describe('authorHandle', () => {
196
+ it('formats as @user@domain', () => {
197
+ const fedContent = ref(makeFedContent());
198
+ const { authorHandle } = useMirrorContent(fedContent);
199
+ expect(authorHandle.value).toBe('@alice@remote.example.com');
200
+ });
201
+
202
+ it('returns empty string when no actor', () => {
203
+ const fedContent = ref(makeFedContent({ actor: null }));
204
+ const { authorHandle } = useMirrorContent(fedContent);
205
+ expect(authorHandle.value).toBe('');
206
+ });
207
+ });
208
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.26",
3
+ "version": "0.3.28",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -44,15 +44,22 @@
44
44
  "vue": "^3.4.0",
45
45
  "vue-router": "^4.3.0",
46
46
  "zod": "^4.3.6",
47
- "@commonpub/auth": "0.5.0",
48
47
  "@commonpub/config": "0.7.1",
49
48
  "@commonpub/protocol": "0.9.5",
50
- "@commonpub/docs": "0.5.2",
51
- "@commonpub/learning": "0.5.0",
49
+ "@commonpub/auth": "0.5.0",
52
50
  "@commonpub/editor": "0.5.0",
53
- "@commonpub/server": "2.15.0",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/schema": "0.8.12"
51
+ "@commonpub/learning": "0.5.0",
52
+ "@commonpub/schema": "0.8.13",
53
+ "@commonpub/server": "2.16.0",
54
+ "@commonpub/docs": "0.5.2",
55
+ "@commonpub/ui": "0.7.1"
56
+ },
57
+ "devDependencies": {
58
+ "@testing-library/jest-dom": "^6.9.1",
59
+ "@testing-library/vue": "^8.1.0",
60
+ "@vitejs/plugin-vue": "^5.2.4",
61
+ "jsdom": "^25.0.1",
62
+ "vitest": "^3.2.4"
56
63
  },
57
64
  "scripts": {}
58
65
  }
@@ -1,12 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  definePageMeta({
3
3
  layout: 'auth',
4
- middleware: 'auth',
4
+ // No middleware: 'auth' — this page must be accessible to unauthenticated users
5
+ // arriving from remote instances during federated OAuth login
5
6
  });
6
7
 
7
8
  useHead({ title: 'Authorize Application' });
8
9
 
9
10
  const route = useRoute();
11
+ const { isAuthenticated, signIn } = useAuth();
10
12
 
11
13
  const clientId = computed(() => route.query.client_id as string ?? '');
12
14
  const redirectUri = computed(() => route.query.redirect_uri as string ?? '');
@@ -17,6 +19,25 @@ const state = computed(() => route.query.state as string ?? '');
17
19
  const loading = ref(false);
18
20
  const error = ref<string | null>(null);
19
21
 
22
+ // Login form state (shown when not authenticated)
23
+ const loginEmail = ref('');
24
+ const loginPassword = ref('');
25
+ const loginLoading = ref(false);
26
+ const loginError = ref<string | null>(null);
27
+
28
+ async function handleLogin() {
29
+ loginLoading.value = true;
30
+ loginError.value = null;
31
+ try {
32
+ await signIn(loginEmail.value, loginPassword.value);
33
+ // After login, stay on this page — consent form will appear
34
+ } catch (err: unknown) {
35
+ loginError.value = err instanceof Error ? err.message : 'Login failed';
36
+ } finally {
37
+ loginLoading.value = false;
38
+ }
39
+ }
40
+
20
41
  async function approve() {
21
42
  loading.value = true;
22
43
  error.value = null;
@@ -48,47 +69,111 @@ function deny() {
48
69
  window.location.href = url.toString();
49
70
  }
50
71
  }
72
+
73
+ // Extract requesting domain from client_id for display
74
+ const requestingDomain = computed(() => {
75
+ const id = clientId.value;
76
+ if (id.startsWith('cpub_')) return id.slice(5);
77
+ try { return new URL(id).hostname; } catch { return id; }
78
+ });
51
79
  </script>
52
80
 
53
81
  <template>
54
82
  <div class="cpub-oauth-consent">
55
- <h1 class="cpub-oauth-consent__title">Authorize Access</h1>
83
+ <!-- Unauthenticated: show login form with context -->
84
+ <template v-if="!isAuthenticated">
85
+ <h1 class="cpub-oauth-consent__title">Sign in to Continue</h1>
86
+
87
+ <p class="cpub-oauth-consent__desc">
88
+ <strong>{{ requestingDomain }}</strong> is requesting access to your account.
89
+ Sign in to review and approve this request.
90
+ </p>
91
+
92
+ <form class="cpub-oauth-login-form" @submit.prevent="handleLogin">
93
+ <div v-if="loginError" class="cpub-oauth-consent__error" role="alert">
94
+ {{ loginError }}
95
+ </div>
96
+
97
+ <label class="cpub-oauth-login-label">
98
+ <span>Email</span>
99
+ <input
100
+ v-model="loginEmail"
101
+ type="email"
102
+ autocomplete="email"
103
+ required
104
+ class="cpub-oauth-login-input"
105
+ />
106
+ </label>
107
+
108
+ <label class="cpub-oauth-login-label">
109
+ <span>Password</span>
110
+ <input
111
+ v-model="loginPassword"
112
+ type="password"
113
+ autocomplete="current-password"
114
+ required
115
+ class="cpub-oauth-login-input"
116
+ />
117
+ </label>
118
+
119
+ <button
120
+ type="submit"
121
+ class="cpub-oauth-consent__btn cpub-oauth-consent__btn--approve"
122
+ :disabled="loginLoading"
123
+ >
124
+ {{ loginLoading ? 'Signing in...' : 'Sign in' }}
125
+ </button>
126
+ </form>
56
127
 
57
- <p class="cpub-oauth-consent__desc">
58
- An application is requesting access to your account.
59
- </p>
128
+ <p class="cpub-oauth-login-alt">
129
+ Don't have an account? <NuxtLink :to="`/auth/register?redirect=${encodeURIComponent(route.fullPath)}`">Sign up</NuxtLink>
130
+ </p>
131
+ </template>
60
132
 
61
- <div class="cpub-oauth-consent__details">
62
- <div class="cpub-oauth-consent__field">
63
- <span class="cpub-oauth-consent__label">Client</span>
64
- <span class="cpub-oauth-consent__value">{{ clientId }}</span>
133
+ <!-- Authenticated: show consent form -->
134
+ <template v-else>
135
+ <h1 class="cpub-oauth-consent__title">Authorize Access</h1>
136
+
137
+ <p class="cpub-oauth-consent__desc">
138
+ <strong>{{ requestingDomain }}</strong> is requesting access to your account.
139
+ </p>
140
+
141
+ <div class="cpub-oauth-consent__details">
142
+ <div class="cpub-oauth-consent__field">
143
+ <span class="cpub-oauth-consent__label">Instance</span>
144
+ <span class="cpub-oauth-consent__value">{{ requestingDomain }}</span>
145
+ </div>
146
+ <div v-if="scope" class="cpub-oauth-consent__field">
147
+ <span class="cpub-oauth-consent__label">Scope</span>
148
+ <span class="cpub-oauth-consent__value">{{ scope }}</span>
149
+ </div>
65
150
  </div>
66
- <div v-if="scope" class="cpub-oauth-consent__field">
67
- <span class="cpub-oauth-consent__label">Scope</span>
68
- <span class="cpub-oauth-consent__value">{{ scope }}</span>
151
+
152
+ <p class="cpub-oauth-consent__permissions">
153
+ This will allow <strong>{{ requestingDomain }}</strong> to verify your identity and access your public profile information.
154
+ </p>
155
+
156
+ <div v-if="error" class="cpub-oauth-consent__error" role="alert">
157
+ {{ error }}
69
158
  </div>
70
- </div>
71
-
72
- <div v-if="error" class="cpub-oauth-consent__error" role="alert">
73
- {{ error }}
74
- </div>
75
-
76
- <div class="cpub-oauth-consent__actions">
77
- <button
78
- class="cpub-oauth-consent__btn cpub-oauth-consent__btn--approve"
79
- :disabled="loading"
80
- @click="approve"
81
- >
82
- {{ loading ? 'Authorizing...' : 'Approve' }}
83
- </button>
84
- <button
85
- class="cpub-oauth-consent__btn cpub-oauth-consent__btn--deny"
86
- :disabled="loading"
87
- @click="deny"
88
- >
89
- Deny
90
- </button>
91
- </div>
159
+
160
+ <div class="cpub-oauth-consent__actions">
161
+ <button
162
+ class="cpub-oauth-consent__btn cpub-oauth-consent__btn--approve"
163
+ :disabled="loading"
164
+ @click="approve"
165
+ >
166
+ {{ loading ? 'Authorizing...' : 'Approve' }}
167
+ </button>
168
+ <button
169
+ class="cpub-oauth-consent__btn cpub-oauth-consent__btn--deny"
170
+ :disabled="loading"
171
+ @click="deny"
172
+ >
173
+ Deny
174
+ </button>
175
+ </div>
176
+ </template>
92
177
  </div>
93
178
  </template>
94
179
 
@@ -136,6 +221,15 @@ function deny() {
136
221
  color: var(--text-1);
137
222
  }
138
223
 
224
+ .cpub-oauth-consent__permissions {
225
+ color: var(--text-2);
226
+ font-size: var(--font-size-sm);
227
+ margin-bottom: var(--space-4);
228
+ padding: var(--space-2) var(--space-3);
229
+ background: var(--surface-2);
230
+ border: var(--border-width-default) solid var(--border);
231
+ }
232
+
139
233
  .cpub-oauth-consent__error {
140
234
  padding: var(--space-2) var(--space-3);
141
235
  border: var(--border-width-default) solid var(--error);
@@ -175,4 +269,46 @@ function deny() {
175
269
  opacity: 0.5;
176
270
  cursor: not-allowed;
177
271
  }
272
+
273
+ /* Login form styles */
274
+ .cpub-oauth-login-form {
275
+ display: flex;
276
+ flex-direction: column;
277
+ gap: var(--space-3);
278
+ margin-bottom: var(--space-4);
279
+ }
280
+
281
+ .cpub-oauth-login-label {
282
+ display: flex;
283
+ flex-direction: column;
284
+ gap: var(--space-1);
285
+ font-family: var(--font-mono);
286
+ font-size: var(--font-size-sm);
287
+ text-transform: uppercase;
288
+ letter-spacing: 0.05em;
289
+ color: var(--text-2);
290
+ }
291
+
292
+ .cpub-oauth-login-input {
293
+ padding: var(--space-2);
294
+ border: var(--border-width-default) solid var(--border);
295
+ background: var(--surface);
296
+ color: var(--text-1);
297
+ font-size: var(--font-size-base);
298
+ }
299
+
300
+ .cpub-oauth-login-input:focus {
301
+ border-color: var(--accent);
302
+ outline: none;
303
+ }
304
+
305
+ .cpub-oauth-login-alt {
306
+ font-size: var(--font-size-sm);
307
+ color: var(--text-2);
308
+ text-align: center;
309
+ }
310
+
311
+ .cpub-oauth-login-alt a {
312
+ color: var(--accent);
313
+ }
178
314
  </style>
@@ -90,13 +90,16 @@ const postsVM = computed<HubPostViewModel[]>(() => {
90
90
  });
91
91
  });
92
92
 
93
- // Extract shared content posts for "Projects" tab
93
+ // Extract shared content posts for "Projects" tab — filter by type to avoid lumping discussions
94
94
  const sharedContentPosts = computed(() =>
95
- postsVM.value.filter(p => p.sharedContent),
95
+ postsVM.value.filter(p => p.sharedContent?.type === 'project'),
96
96
  );
97
97
 
98
98
  const discussionPosts = computed(() =>
99
- postsVM.value.filter(p => p.type === 'text' || p.type === 'discussion' || p.type === 'question'),
99
+ postsVM.value.filter(p =>
100
+ (p.type === 'text' || p.type === 'discussion' || p.type === 'question')
101
+ && !p.sharedContent,
102
+ ),
100
103
  );
101
104
 
102
105
  // Hub rules (from federated metadata)
@@ -87,9 +87,13 @@ async function handleReply(): Promise<void> {
87
87
  }
88
88
  }
89
89
 
90
+ function stripHtml(html: string): string {
91
+ return html.replace(/<[^>]*>/g, '').trim();
92
+ }
93
+
90
94
  useSeoMeta({
91
- title: () => post.value ? `${post.value.content?.slice(0, 60)} — ${hub.value?.name ?? 'Hub'}` : 'Post',
92
- description: () => post.value?.content?.slice(0, 160) ?? '',
95
+ title: () => post.value ? `${stripHtml(post.value.content || '').slice(0, 60)} — ${hub.value?.name ?? 'Hub'}` : 'Post',
96
+ description: () => stripHtml(post.value?.content ?? '').slice(0, 160),
93
97
  });
94
98
 
95
99
  useHead({
@@ -128,7 +132,9 @@ useHead({
128
132
  <span class="cpub-post-type-badge">{{ post.postType }}</span>
129
133
  </div>
130
134
 
131
- <div class="cpub-post-content">{{ post.content }}</div>
135
+ <!-- Content is sanitized server-side via sanitizeHtml() before storage -->
136
+ <!-- eslint-disable-next-line vue/no-v-html -->
137
+ <div class="cpub-post-content cpub-prose" v-html="post.content"></div>
132
138
 
133
139
  <div class="cpub-post-meta">
134
140
  <div class="cpub-post-author">
@@ -161,7 +161,7 @@ useSeoMeta({
161
161
  </div>
162
162
  </div>
163
163
  <div v-else class="cpub-post-content">
164
- {{ post.content }}
164
+ <MentionText :text="post.content || ''" />
165
165
  <div v-if="post.updatedAt && post.updatedAt !== post.createdAt" class="cpub-post-edited">
166
166
  <i class="fa-solid fa-pen"></i> edited
167
167
  </div>
package/pages/search.vue CHANGED
@@ -2,7 +2,7 @@
2
2
  import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
3
3
 
4
4
  useSeoMeta({
5
- title: 'Search -- devEco.io',
5
+ title: `Search ${useSiteName()}`,
6
6
  description: 'Search for projects, articles, people, and communities.',
7
7
  });
8
8
 
@@ -3,8 +3,9 @@ definePageMeta({ middleware: 'auth' });
3
3
 
4
4
  const { themeId: theme, setTheme } = useTheme();
5
5
  const themes = [
6
- { id: 'base', name: 'Light', desc: 'Sharp corners, offset shadows' },
7
- { id: 'dark', name: 'Dark', desc: 'Dark surfaces, same aesthetic' },
6
+ { id: 'base', name: 'Light', desc: 'Sharp corners, offset shadows, blue accent' },
7
+ { id: 'dark', name: 'Dark', desc: 'Dark surfaces, same offset shadow aesthetic' },
8
+ { id: 'generics', name: 'Generics', desc: 'Dark minimal with soft glow' },
8
9
  ];
9
10
  </script>
10
11
 
@@ -35,7 +36,7 @@ const themes = [
35
36
  <style scoped>
36
37
  .cpub-theme-grid {
37
38
  display: grid;
38
- grid-template-columns: repeat(2, 1fr);
39
+ grid-template-columns: repeat(3, 1fr);
39
40
  gap: 12px;
40
41
  }
41
42
 
@@ -74,7 +75,7 @@ const themes = [
74
75
  color: var(--text-dim);
75
76
  }
76
77
 
77
- @media (max-width: 480px) {
78
+ @media (max-width: 640px) {
78
79
  .cpub-theme-grid { grid-template-columns: 1fr; }
79
80
  }
80
81
  </style>
@@ -6,6 +6,7 @@
6
6
  import {
7
7
  refreshFederatedHubMetadata,
8
8
  backfillHubFromOutbox,
9
+ fetchRemoteHubFollowers,
9
10
  } from '@commonpub/server';
10
11
  import { federatedHubs } from '@commonpub/schema';
11
12
  import { eq, and, or, lt, isNull } from 'drizzle-orm';
@@ -83,6 +84,19 @@ export default defineNitroPlugin((nitro) => {
83
84
  // Refresh metadata (name, description, icon, member count)
84
85
  await refreshFederatedHubMetadata(db, hub.id, hub.actorUri);
85
86
 
87
+ // Fetch followers to populate members list (first sync or periodic refresh)
88
+ if (!hub.lastSyncAt) {
89
+ // First sync — fetch followers to seed the members table
90
+ try {
91
+ const result = await fetchRemoteHubFollowers(db, hub.id, domain);
92
+ if (result.fetched > 0) {
93
+ console.log(`[hub-sync] Fetched ${result.fetched} followers for ${hub.name}`);
94
+ }
95
+ } catch (err) {
96
+ console.warn(`[hub-sync] Followers fetch failed for ${hub.name}:`, err instanceof Error ? err.message : err);
97
+ }
98
+ }
99
+
86
100
  // Optionally backfill new posts from outbox
87
101
  if (backfillOnSync) {
88
102
  const result = await backfillHubFromOutbox(db, hub.id, domain);
@@ -0,0 +1,71 @@
1
+ import { contentToArticle } from '@commonpub/protocol';
2
+ import { contentItems, users } from '@commonpub/schema';
3
+ import { eq, and, isNull } from 'drizzle-orm';
4
+
5
+ /**
6
+ * Content AP Article endpoint.
7
+ * Serves Article JSON-LD when requested with AP Accept header.
8
+ * Remote instances dereference this URI when processing:
9
+ * - Create activities (content federation)
10
+ * - Announce activities (hub share federation)
11
+ * - Like/Boost activities targeting content
12
+ */
13
+ export default defineEventHandler(async (event) => {
14
+ const accept = getRequestHeader(event, 'accept') ?? '';
15
+ const isAPRequest =
16
+ accept.includes('application/activity+json') ||
17
+ accept.includes('application/ld+json');
18
+
19
+ if (!isAPRequest) return;
20
+
21
+ const config = useConfig();
22
+ if (!config.features.federation) return;
23
+
24
+ const slug = getRouterParam(event, 'slug');
25
+ if (!slug) return;
26
+
27
+ const db = useDB();
28
+ const domain = config.instance.domain;
29
+
30
+ const [row] = await db
31
+ .select({
32
+ content: contentItems,
33
+ author: {
34
+ username: users.username,
35
+ displayName: users.displayName,
36
+ },
37
+ })
38
+ .from(contentItems)
39
+ .innerJoin(users, eq(contentItems.authorId, users.id))
40
+ .where(and(
41
+ eq(contentItems.slug, slug),
42
+ eq(contentItems.status, 'published'),
43
+ isNull(contentItems.deletedAt),
44
+ ))
45
+ .limit(1);
46
+
47
+ if (!row) return;
48
+
49
+ setResponseHeader(event, 'content-type', 'application/activity+json');
50
+
51
+ // Render the content as an AP Article with all CommonPub extensions
52
+ const article = contentToArticle(
53
+ {
54
+ id: row.content.id,
55
+ type: row.content.type,
56
+ title: row.content.title,
57
+ slug: row.content.slug,
58
+ description: row.content.description,
59
+ content: typeof row.content.content === 'string'
60
+ ? row.content.content
61
+ : JSON.stringify(row.content.content),
62
+ coverImageUrl: row.content.coverImageUrl,
63
+ publishedAt: row.content.publishedAt,
64
+ updatedAt: row.content.updatedAt,
65
+ },
66
+ { username: row.author.username, displayName: row.author.displayName ?? row.author.username },
67
+ domain,
68
+ );
69
+
70
+ return article;
71
+ });
@@ -33,8 +33,10 @@ export default defineEventHandler(async (event) => {
33
33
  .select({
34
34
  id: hubPosts.id,
35
35
  content: hubPosts.content,
36
+ type: hubPosts.type,
36
37
  createdAt: hubPosts.createdAt,
37
38
  authorUsername: users.username,
39
+ authorDisplayName: users.displayName,
38
40
  })
39
41
  .from(hubPosts)
40
42
  .innerJoin(users, eq(hubPosts.authorId, users.id))
@@ -49,15 +51,43 @@ export default defineEventHandler(async (event) => {
49
51
 
50
52
  setResponseHeader(event, 'content-type', 'application/activity+json');
51
53
 
54
+ // Build Note content — share posts need special handling
55
+ let noteContent = escapeHtmlForAP(post.content);
56
+ const ext: Record<string, unknown> = {};
57
+
58
+ if (post.type === 'share') {
59
+ try {
60
+ const shared = JSON.parse(post.content) as Record<string, unknown>;
61
+ ext['cpub:sharedContent'] = {
62
+ type: shared.type ?? 'article',
63
+ title: shared.title ?? '',
64
+ summary: shared.description ?? null,
65
+ coverImageUrl: shared.coverImageUrl ?? null,
66
+ originUrl: shared.slug
67
+ ? `https://${domain}/${shared.type}/${shared.slug}`
68
+ : null,
69
+ originDomain: domain,
70
+ };
71
+ const displayName = post.authorDisplayName ?? post.authorUsername;
72
+ const title = shared.title ? String(shared.title) : 'content';
73
+ noteContent = escapeHtmlForAP(`${displayName} shared: ${title}`);
74
+ } catch { /* fallback to raw content */ }
75
+ }
76
+
77
+ if (post.type && post.type !== 'text') {
78
+ ext['cpub:postType'] = post.type;
79
+ }
80
+
52
81
  return {
53
82
  '@context': AP_CONTEXT,
54
83
  type: 'Note',
55
84
  id: noteUri,
56
85
  attributedTo: actorUri,
57
- content: escapeHtmlForAP(post.content),
86
+ content: noteContent,
58
87
  published: post.createdAt.toISOString(),
59
88
  to: [AP_PUBLIC],
60
89
  cc: [`${hubActorUri}/followers`],
61
90
  context: hubActorUri,
91
+ ...ext,
62
92
  };
63
93
  });