@commonpub/layer 0.3.36 → 0.3.38

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.
@@ -183,8 +183,9 @@ const userUsername = computed(() => user.value?.username ?? '');
183
183
  <h4 class="cpub-footer-col-title">Platform</h4>
184
184
  <NuxtLink to="/about" class="cpub-footer-link">About</NuxtLink>
185
185
  <NuxtLink v-if="docs" to="/docs" class="cpub-footer-link">Docs</NuxtLink>
186
+ <NuxtLink to="/privacy" class="cpub-footer-link">Privacy Policy</NuxtLink>
187
+ <NuxtLink to="/terms" class="cpub-footer-link">Terms of Service</NuxtLink>
186
188
  <a href="/feed.xml" class="cpub-footer-link">RSS Feed</a>
187
- <a href="/sitemap.xml" class="cpub-footer-link">Sitemap</a>
188
189
  </nav>
189
190
  </div>
190
191
  <div class="cpub-footer-bottom">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.36",
3
+ "version": "0.3.38",
4
4
  "type": "module",
5
5
  "main": "./nuxt.config.ts",
6
6
  "files": [
@@ -15,7 +15,12 @@
15
15
  "plugins",
16
16
  "server",
17
17
  "theme",
18
- "types"
18
+ "types",
19
+ "!**/__tests__/",
20
+ "!**/*.test.ts",
21
+ "!**/*.spec.ts",
22
+ "!vitest.config.ts",
23
+ "!test-setup.ts"
19
24
  ],
20
25
  "publishConfig": {
21
26
  "access": "public"
@@ -44,14 +49,14 @@
44
49
  "vue": "^3.4.0",
45
50
  "vue-router": "^4.3.0",
46
51
  "zod": "^4.3.6",
47
- "@commonpub/config": "0.7.1",
48
52
  "@commonpub/auth": "0.5.0",
49
- "@commonpub/editor": "0.5.0",
53
+ "@commonpub/config": "0.7.1",
50
54
  "@commonpub/docs": "0.5.2",
55
+ "@commonpub/editor": "0.5.0",
51
56
  "@commonpub/learning": "0.5.0",
52
57
  "@commonpub/protocol": "0.9.5",
58
+ "@commonpub/server": "2.21.0",
53
59
  "@commonpub/schema": "0.8.13",
54
- "@commonpub/server": "2.20.1",
55
60
  "@commonpub/ui": "0.7.1"
56
61
  },
57
62
  "devDependencies": {
@@ -101,6 +101,13 @@ async function handleSubmit(): Promise<void> {
101
101
  />
102
102
  </div>
103
103
 
104
+ <p class="register-legal">
105
+ By creating an account, you agree to our
106
+ <NuxtLink to="/terms">Terms of Service</NuxtLink>
107
+ and acknowledge our
108
+ <NuxtLink to="/privacy">Privacy Policy</NuxtLink>.
109
+ </p>
110
+
104
111
  <button type="submit" class="submit-btn" :disabled="loading">
105
112
  {{ loading ? 'Creating...' : 'Create account' }}
106
113
  </button>
@@ -206,6 +213,21 @@ async function handleSubmit(): Promise<void> {
206
213
  cursor: not-allowed;
207
214
  }
208
215
 
216
+ .register-legal {
217
+ font-size: 11px;
218
+ color: var(--text-faint);
219
+ line-height: 1.5;
220
+ }
221
+
222
+ .register-legal a {
223
+ color: var(--accent);
224
+ text-decoration: none;
225
+ }
226
+
227
+ .register-legal a:hover {
228
+ text-decoration: underline;
229
+ }
230
+
209
231
  .register-footer {
210
232
  text-align: center;
211
233
  font-size: 12px;
@@ -0,0 +1,206 @@
1
+ <script setup lang="ts">
2
+ useSeoMeta({
3
+ title: `Privacy Policy — ${useSiteName()}`,
4
+ description: 'How we collect, use, and protect your personal data.',
5
+ });
6
+
7
+ const siteName = useSiteName();
8
+ const { federation: federationEnabled } = useFeatures();
9
+ </script>
10
+
11
+ <template>
12
+ <div class="cpub-legal">
13
+ <div class="cpub-legal-header">
14
+ <h1 class="cpub-legal-title">Privacy Policy</h1>
15
+ <p class="cpub-legal-updated">Last updated: April 2026</p>
16
+ </div>
17
+
18
+ <div class="cpub-legal-body">
19
+ <section class="cpub-legal-section">
20
+ <h2>1. Who We Are</h2>
21
+ <p>
22
+ This {{ siteName }} instance is operated by its administrator (the "data controller").
23
+ {{ siteName }} is powered by <a href="https://commonpub.io" target="_blank" rel="noopener">CommonPub</a>,
24
+ an open-source, self-hosted platform. Each instance is independently operated and responsible for its own data processing.
25
+ </p>
26
+ </section>
27
+
28
+ <section class="cpub-legal-section">
29
+ <h2>2. What Data We Collect</h2>
30
+ <p>When you create an account, we collect:</p>
31
+ <ul>
32
+ <li><strong>Account data:</strong> email address, username, password (stored as a secure hash)</li>
33
+ <li><strong>Profile data:</strong> display name, bio, headline, location, website, avatar, banner image, social links, skills, pronouns, timezone (all optional)</li>
34
+ <li><strong>Content:</strong> projects, articles, blog posts, comments, and other content you create</li>
35
+ <li><strong>Activity data:</strong> likes, follows, bookmarks, hub memberships, learning path enrollments</li>
36
+ <li><strong>Messages:</strong> direct messages you send to other users on this instance</li>
37
+ </ul>
38
+ <p>We also automatically collect:</p>
39
+ <ul>
40
+ <li><strong>Session data:</strong> IP address and browser user agent when you log in, stored for the duration of your session (up to 7 days)</li>
41
+ <li><strong>Theme preference:</strong> your light/dark mode choice, stored in your browser's local storage</li>
42
+ </ul>
43
+ </section>
44
+
45
+ <section class="cpub-legal-section">
46
+ <h2>3. How We Use Your Data</h2>
47
+ <ul>
48
+ <li><strong>Providing the service:</strong> displaying your profile, publishing your content, delivering notifications</li>
49
+ <li><strong>Authentication:</strong> verifying your identity when you log in</li>
50
+ <li><strong>Security:</strong> protecting against unauthorized access, abuse, and spam</li>
51
+ <li><strong>Email notifications:</strong> sending notification digests and alerts you've opted into (configurable in settings)</li>
52
+ </ul>
53
+ </section>
54
+
55
+ <section class="cpub-legal-section">
56
+ <h2>4. Legal Basis for Processing</h2>
57
+ <p>We process your data under the following legal bases (GDPR Article 6):</p>
58
+ <ul>
59
+ <li><strong>Contract performance (Art. 6(1)(b)):</strong> processing necessary to provide you with the service you signed up for</li>
60
+ <li><strong>Legitimate interest (Art. 6(1)(f)):</strong> session security, rate limiting, and preventing abuse</li>
61
+ </ul>
62
+ </section>
63
+
64
+ <section class="cpub-legal-section">
65
+ <h2>5. Cookies and Local Storage</h2>
66
+ <p>We use only <strong>strictly necessary</strong> technologies:</p>
67
+ <ul>
68
+ <li><strong>Session cookie</strong> (<code>better-auth.session_token</code>): an authentication cookie that identifies your login session. It is httpOnly (not accessible to JavaScript), secure (HTTPS-only in production), and expires after 7 days. This cookie is strictly necessary for the service to function and does not require consent.</li>
69
+ <li><strong>Theme preference</strong> (<code>cpub-theme</code> in localStorage): stores your light/dark mode choice in your browser. This is a UI preference, not used for tracking.</li>
70
+ </ul>
71
+ <p>We do not use any analytics, advertising, or tracking cookies. No cookie consent banner is required because we only use strictly necessary cookies.</p>
72
+ </section>
73
+
74
+ <section v-if="federationEnabled" class="cpub-legal-section">
75
+ <h2>6. Federation and ActivityPub</h2>
76
+ <p>This instance participates in the <a href="https://activitypub.rocks" target="_blank" rel="noopener">ActivityPub</a> federation protocol. When you publish content or interact publicly, the following data may be shared with remote instances:</p>
77
+ <ul>
78
+ <li>Your username, display name, avatar, and bio</li>
79
+ <li>Your published content (projects, articles, blog posts)</li>
80
+ <li>Your public interactions (likes, follows, comments on federated content)</li>
81
+ </ul>
82
+ <p>Your email address, location, social links, timezone, and other private profile fields are <strong>never</strong> shared via federation.</p>
83
+ <p><strong>Important:</strong> Once your data is federated to remote instances, this instance cannot guarantee its deletion on those servers. Remote instances operate independently and may retain cached copies of your public data even after you delete your account here.</p>
84
+ </section>
85
+
86
+ <section class="cpub-legal-section">
87
+ <h2>{{ federationEnabled ? '7' : '6' }}. Third-Party Services</h2>
88
+ <p>We load icon fonts from <strong>Font Awesome</strong> via the Cloudflare CDN (<code>cdnjs.cloudflare.com</code>). This means your browser makes requests to Cloudflare's servers, which are subject to <a href="https://www.cloudflare.com/privacypolicy/" target="_blank" rel="noopener">Cloudflare's privacy policy</a>.</p>
89
+ <p>We do not use any analytics services, advertising networks, or tracking technologies.</p>
90
+ </section>
91
+
92
+ <section class="cpub-legal-section">
93
+ <h2>{{ federationEnabled ? '8' : '7' }}. Data Retention</h2>
94
+ <ul>
95
+ <li><strong>Account data:</strong> retained until you delete your account</li>
96
+ <li><strong>Session data:</strong> automatically expires after 7 days of inactivity</li>
97
+ <li><strong>Content:</strong> retained until you delete it or delete your account</li>
98
+ <li><strong>Audit logs:</strong> retained per the instance operator's policy</li>
99
+ </ul>
100
+ </section>
101
+
102
+ <section class="cpub-legal-section">
103
+ <h2>{{ federationEnabled ? '9' : '8' }}. Your Rights</h2>
104
+ <p>Under the GDPR and similar data protection laws, you have the right to:</p>
105
+ <ul>
106
+ <li><strong>Access:</strong> view the data we hold about you (via your profile and settings)</li>
107
+ <li><strong>Rectification:</strong> update or correct your data (via your profile settings)</li>
108
+ <li><strong>Erasure:</strong> delete your account and all associated data (via account settings)</li>
109
+ <li><strong>Portability:</strong> download your data in a machine-readable format (via account settings)</li>
110
+ <li><strong>Restriction and objection:</strong> contact the instance administrator</li>
111
+ </ul>
112
+ <p>To exercise these rights, visit your <NuxtLink to="/settings/account">account settings</NuxtLink> or contact the instance administrator.</p>
113
+ </section>
114
+
115
+ <section class="cpub-legal-section">
116
+ <h2>{{ federationEnabled ? '10' : '9' }}. Contact</h2>
117
+ <p>For privacy-related inquiries, contact the administrator of this {{ siteName }} instance.</p>
118
+ </section>
119
+ </div>
120
+ </div>
121
+ </template>
122
+
123
+ <style scoped>
124
+ .cpub-legal {
125
+ max-width: 740px;
126
+ margin: 0 auto;
127
+ padding: 48px 24px 80px;
128
+ }
129
+
130
+ .cpub-legal-header {
131
+ margin-bottom: 40px;
132
+ }
133
+
134
+ .cpub-legal-title {
135
+ font-size: 28px;
136
+ font-weight: 700;
137
+ margin-bottom: 8px;
138
+ }
139
+
140
+ .cpub-legal-updated {
141
+ font-size: 12px;
142
+ color: var(--text-faint);
143
+ font-family: var(--font-mono);
144
+ }
145
+
146
+ .cpub-legal-body {
147
+ display: flex;
148
+ flex-direction: column;
149
+ gap: 32px;
150
+ }
151
+
152
+ .cpub-legal-section h2 {
153
+ font-size: 16px;
154
+ font-weight: 600;
155
+ margin-bottom: 12px;
156
+ }
157
+
158
+ .cpub-legal-section p {
159
+ font-size: 14px;
160
+ line-height: 1.7;
161
+ color: var(--text-dim);
162
+ margin-bottom: 8px;
163
+ }
164
+
165
+ .cpub-legal-section ul {
166
+ padding-left: 20px;
167
+ margin: 8px 0;
168
+ }
169
+
170
+ .cpub-legal-section li {
171
+ font-size: 14px;
172
+ line-height: 1.7;
173
+ color: var(--text-dim);
174
+ margin-bottom: 4px;
175
+ }
176
+
177
+ .cpub-legal-section strong {
178
+ color: var(--text);
179
+ }
180
+
181
+ .cpub-legal-section code {
182
+ font-family: var(--font-mono);
183
+ font-size: 12px;
184
+ padding: 1px 5px;
185
+ background: var(--surface2);
186
+ border: var(--border-width-default) solid var(--border);
187
+ }
188
+
189
+ .cpub-legal-section a {
190
+ color: var(--accent);
191
+ text-decoration: none;
192
+ }
193
+
194
+ .cpub-legal-section a:hover {
195
+ text-decoration: underline;
196
+ }
197
+
198
+ @media (max-width: 640px) {
199
+ .cpub-legal {
200
+ padding: 24px 16px 60px;
201
+ }
202
+ .cpub-legal-title {
203
+ font-size: 22px;
204
+ }
205
+ }
206
+ </style>
package/pages/search.vue CHANGED
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- import type { Serialized, ContentListItem, PaginatedResponse } from '@commonpub/server';
2
+ import type { PaginatedResponse } from '@commonpub/server';
3
3
 
4
4
  useSeoMeta({
5
5
  title: `Search — ${useSiteName()}`,
@@ -67,7 +67,8 @@ const searchQuery = computed(() => ({
67
67
  community: communityFilter.value || undefined,
68
68
  }));
69
69
 
70
- const { data: results, status } = await useFetch<PaginatedResponse<Serialized<ContentListItem>>>('/api/search', {
70
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ const { data: results, status } = await useFetch<PaginatedResponse<any>>('/api/search', {
71
72
  query: searchQuery,
72
73
  watch: [searchQuery],
73
74
  lazy: true,
@@ -86,6 +86,14 @@ async function handleDeleteAccount(): Promise<void> {
86
86
  </button>
87
87
  </form>
88
88
 
89
+ <div class="cpub-form-group">
90
+ <label class="cpub-form-label">Your Data</label>
91
+ <p class="cpub-form-hint cpub-mb-2">Download a copy of all your data in JSON format.</p>
92
+ <a href="/api/auth/export-data" download class="cpub-btn cpub-btn-sm">
93
+ <i class="fa-solid fa-download"></i> Download My Data
94
+ </a>
95
+ </div>
96
+
89
97
  <hr class="cpub-danger-divider" />
90
98
 
91
99
  <div>
@@ -126,6 +134,7 @@ async function handleDeleteAccount(): Promise<void> {
126
134
 
127
135
  <style scoped>
128
136
  .cpub-mt-2 { margin-top: var(--space-2); }
137
+ .cpub-mb-2 { margin-bottom: var(--space-2); }
129
138
 
130
139
  .cpub-danger-divider {
131
140
  border: none;
@@ -0,0 +1,168 @@
1
+ <script setup lang="ts">
2
+ useSeoMeta({
3
+ title: `Terms of Service — ${useSiteName()}`,
4
+ description: 'Terms and conditions for using this platform.',
5
+ });
6
+
7
+ const siteName = useSiteName();
8
+ </script>
9
+
10
+ <template>
11
+ <div class="cpub-legal">
12
+ <div class="cpub-legal-header">
13
+ <h1 class="cpub-legal-title">Terms of Service</h1>
14
+ <p class="cpub-legal-updated">Last updated: April 2026</p>
15
+ </div>
16
+
17
+ <div class="cpub-legal-body">
18
+ <section class="cpub-legal-section">
19
+ <h2>1. Acceptance</h2>
20
+ <p>By creating an account or using {{ siteName }}, you agree to these Terms of Service and our <NuxtLink to="/privacy">Privacy Policy</NuxtLink>. If you do not agree, do not use the service.</p>
21
+ </section>
22
+
23
+ <section class="cpub-legal-section">
24
+ <h2>2. Your Account</h2>
25
+ <ul>
26
+ <li>You must provide accurate information when creating an account.</li>
27
+ <li>You are responsible for keeping your login credentials secure.</li>
28
+ <li>You must be at least 16 years old to create an account (or the minimum age required by your jurisdiction).</li>
29
+ <li>One person, one account. Automated or bot accounts require prior approval from the instance administrator.</li>
30
+ </ul>
31
+ </section>
32
+
33
+ <section class="cpub-legal-section">
34
+ <h2>3. Your Content</h2>
35
+ <p>You retain ownership of content you create on {{ siteName }}. By publishing content, you grant us a license to display, distribute, and federate it as part of operating the platform.</p>
36
+ <ul>
37
+ <li>You must have the right to publish any content you upload (no plagiarism, no copyright infringement).</li>
38
+ <li>Content you import must be your own original work.</li>
39
+ <li>You may delete your content at any time. Federated copies on remote instances are outside our control.</li>
40
+ <li>We may remove content that violates these terms or applicable law.</li>
41
+ </ul>
42
+ </section>
43
+
44
+ <section class="cpub-legal-section">
45
+ <h2>4. Acceptable Use</h2>
46
+ <p>You agree not to:</p>
47
+ <ul>
48
+ <li>Post illegal content or content that infringes others' rights</li>
49
+ <li>Harass, threaten, or abuse other users</li>
50
+ <li>Spam, phish, or distribute malware</li>
51
+ <li>Attempt to gain unauthorized access to the platform or other users' accounts</li>
52
+ <li>Scrape, crawl, or automatically collect data from the platform beyond what public APIs allow</li>
53
+ <li>Impersonate another person or entity</li>
54
+ <li>Use the platform for commercial advertising without permission</li>
55
+ </ul>
56
+ </section>
57
+
58
+ <section class="cpub-legal-section">
59
+ <h2>5. Moderation</h2>
60
+ <p>The instance administrator may, at their discretion:</p>
61
+ <ul>
62
+ <li>Remove content that violates these terms</li>
63
+ <li>Suspend or delete accounts that violate these terms</li>
64
+ <li>Block federation with other instances</li>
65
+ </ul>
66
+ <p>We aim to handle moderation decisions transparently and fairly.</p>
67
+ </section>
68
+
69
+ <section class="cpub-legal-section">
70
+ <h2>6. Availability and Changes</h2>
71
+ <p>We provide {{ siteName }} on an "as is" basis. We do not guarantee uninterrupted availability. We may modify or discontinue the service at any time.</p>
72
+ <p>These terms may be updated. Continued use after changes constitutes acceptance.</p>
73
+ </section>
74
+
75
+ <section class="cpub-legal-section">
76
+ <h2>7. Limitation of Liability</h2>
77
+ <p>To the fullest extent permitted by law, the instance operator is not liable for any indirect, incidental, or consequential damages arising from your use of the platform. This includes data loss, service interruptions, or actions of other users or federated instances.</p>
78
+ </section>
79
+
80
+ <section class="cpub-legal-section">
81
+ <h2>8. Account Deletion</h2>
82
+ <p>You may delete your account at any time from your <NuxtLink to="/settings/account">account settings</NuxtLink>. Account deletion is permanent and removes all your data, content, and activity from this instance. See our <NuxtLink to="/privacy">Privacy Policy</NuxtLink> for details on data retention and federation.</p>
83
+ </section>
84
+
85
+ <section class="cpub-legal-section">
86
+ <h2>9. Contact</h2>
87
+ <p>For questions about these terms, contact the administrator of this {{ siteName }} instance.</p>
88
+ </section>
89
+ </div>
90
+ </div>
91
+ </template>
92
+
93
+ <style scoped>
94
+ .cpub-legal {
95
+ max-width: 740px;
96
+ margin: 0 auto;
97
+ padding: 48px 24px 80px;
98
+ }
99
+
100
+ .cpub-legal-header {
101
+ margin-bottom: 40px;
102
+ }
103
+
104
+ .cpub-legal-title {
105
+ font-size: 28px;
106
+ font-weight: 700;
107
+ margin-bottom: 8px;
108
+ }
109
+
110
+ .cpub-legal-updated {
111
+ font-size: 12px;
112
+ color: var(--text-faint);
113
+ font-family: var(--font-mono);
114
+ }
115
+
116
+ .cpub-legal-body {
117
+ display: flex;
118
+ flex-direction: column;
119
+ gap: 32px;
120
+ }
121
+
122
+ .cpub-legal-section h2 {
123
+ font-size: 16px;
124
+ font-weight: 600;
125
+ margin-bottom: 12px;
126
+ }
127
+
128
+ .cpub-legal-section p {
129
+ font-size: 14px;
130
+ line-height: 1.7;
131
+ color: var(--text-dim);
132
+ margin-bottom: 8px;
133
+ }
134
+
135
+ .cpub-legal-section ul {
136
+ padding-left: 20px;
137
+ margin: 8px 0;
138
+ }
139
+
140
+ .cpub-legal-section li {
141
+ font-size: 14px;
142
+ line-height: 1.7;
143
+ color: var(--text-dim);
144
+ margin-bottom: 4px;
145
+ }
146
+
147
+ .cpub-legal-section strong {
148
+ color: var(--text);
149
+ }
150
+
151
+ .cpub-legal-section a {
152
+ color: var(--accent);
153
+ text-decoration: none;
154
+ }
155
+
156
+ .cpub-legal-section a:hover {
157
+ text-decoration: underline;
158
+ }
159
+
160
+ @media (max-width: 640px) {
161
+ .cpub-legal {
162
+ padding: 24px 16px 60px;
163
+ }
164
+ .cpub-legal-title {
165
+ font-size: 22px;
166
+ }
167
+ }
168
+ </style>
@@ -0,0 +1,55 @@
1
+ import { deleteUser, federateDelete, listContent } from '@commonpub/server';
2
+ import { contentItems } from '@commonpub/schema';
3
+ import { eq, and } from 'drizzle-orm';
4
+
5
+ export default defineEventHandler(async (event): Promise<{ success: true }> => {
6
+ const user = requireAuth(event);
7
+ const db = useDB();
8
+ const config = useConfig();
9
+
10
+ // Prevent deleting the last admin
11
+ if (user.role === 'admin') {
12
+ const { users } = await import('@commonpub/schema');
13
+ const admins = await db
14
+ .select({ id: users.id })
15
+ .from(users)
16
+ .where(eq(users.role, 'admin'))
17
+ .limit(2);
18
+ if (admins.length <= 1) {
19
+ throw createError({
20
+ statusCode: 400,
21
+ statusMessage: 'Cannot delete the only admin account',
22
+ });
23
+ }
24
+ }
25
+
26
+ // Federation cleanup: send Delete activities for published content
27
+ if (config.features.federation) {
28
+ const domain = config.instance.domain;
29
+ if (domain) {
30
+ const published = await db
31
+ .select({ id: contentItems.id })
32
+ .from(contentItems)
33
+ .where(and(
34
+ eq(contentItems.authorId, user.id),
35
+ eq(contentItems.status, 'published'),
36
+ ));
37
+
38
+ for (const item of published) {
39
+ try {
40
+ await federateDelete(db, item.id, domain, user.username);
41
+ } catch {
42
+ // Best-effort — don't block deletion if federation fails
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ // Delete the user (cascades to all related data)
49
+ await deleteUser(db, user.id, user.id);
50
+
51
+ // Clear the session cookie
52
+ deleteCookie(event, 'better-auth.session_token', { path: '/' });
53
+
54
+ return { success: true };
55
+ });
@@ -0,0 +1,15 @@
1
+ import { exportUserData } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ const user = requireAuth(event);
5
+ const db = useDB();
6
+
7
+ const data = await exportUserData(db, user.id);
8
+
9
+ const filename = `commonpub-export-${user.username}-${new Date().toISOString().split('T')[0]}.json`;
10
+
11
+ setHeader(event, 'Content-Type', 'application/json');
12
+ setHeader(event, 'Content-Disposition', `attachment; filename="${filename}"`);
13
+
14
+ return data;
15
+ });
@@ -1,5 +1,5 @@
1
1
  import { searchContent, listHubs, escapeLike } from '@commonpub/server';
2
- import type { ContentSearchOptions } from '@commonpub/server';
2
+ import type { ContentSearchOptions, MeiliClient } from '@commonpub/server';
3
3
  import { users, follows, hubs } from '@commonpub/schema';
4
4
  import { sql, desc, ilike, or, and, isNull, eq } from 'drizzle-orm';
5
5
  import { z } from 'zod';
@@ -45,7 +45,7 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
45
45
  bannerUrl: hub.bannerUrl,
46
46
  memberCount: hub.memberCount,
47
47
  postCount: hub.postCount,
48
- source: (hub as Record<string, unknown>).source ?? 'local',
48
+ source: (hub as unknown as Record<string, unknown>).source ?? 'local',
49
49
  })),
50
50
  total: result.total,
51
51
  };
@@ -108,7 +108,7 @@ export default defineEventHandler(async (event): Promise<{ items: unknown[]; tot
108
108
  const meiliKey = process.env.MEILI_MASTER_KEY;
109
109
  if (meiliUrl) {
110
110
  const { MeiliSearch } = await import('meilisearch');
111
- meiliClient = new MeiliSearch({ host: meiliUrl, apiKey: meiliKey });
111
+ meiliClient = new MeiliSearch({ host: meiliUrl, apiKey: meiliKey }) as unknown as MeiliClient;
112
112
  }
113
113
  } catch { /* Meilisearch not available */ }
114
114
 
@@ -0,0 +1,5 @@
1
+ declare module 'meilisearch' {
2
+ export class MeiliSearch {
3
+ constructor(options: { host: string; apiKey?: string });
4
+ }
5
+ }
@@ -1,340 +0,0 @@
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
- });
@@ -1,208 +0,0 @@
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
- });