@commonpub/layer 0.3.35 → 0.3.37

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.
@@ -0,0 +1,449 @@
1
+ <script setup lang="ts">
2
+ import type { BlockTuple } from '@commonpub/editor';
3
+
4
+ const props = defineProps<{
5
+ show: boolean;
6
+ }>();
7
+
8
+ const emit = defineEmits<{
9
+ close: [];
10
+ imported: [result: ImportedContent];
11
+ }>();
12
+
13
+ interface ImportedContent {
14
+ title: string;
15
+ description: string;
16
+ coverImageUrl: string | null;
17
+ content: BlockTuple[];
18
+ tags: string[];
19
+ partial: boolean;
20
+ meta: Record<string, unknown>;
21
+ }
22
+
23
+ const url = ref('');
24
+ const loading = ref(false);
25
+ const error = ref('');
26
+ const result = ref<ImportedContent | null>(null);
27
+ const confirmed = ref(false);
28
+
29
+ const canSubmit = computed(() => {
30
+ try {
31
+ const parsed = new URL(url.value);
32
+ return parsed.protocol === 'https:' || parsed.protocol === 'http:';
33
+ } catch {
34
+ return false;
35
+ }
36
+ });
37
+
38
+ async function handleFetch(): Promise<void> {
39
+ if (!canSubmit.value) return;
40
+ loading.value = true;
41
+ error.value = '';
42
+ result.value = null;
43
+
44
+ try {
45
+ const data = await $fetch<ImportedContent>('/api/content/import', {
46
+ method: 'POST',
47
+ body: { url: url.value },
48
+ });
49
+ result.value = data;
50
+ } catch (err: unknown) {
51
+ const msg = (err as { data?: { message?: string } })?.data?.message
52
+ || (err as Error)?.message
53
+ || 'Failed to import content';
54
+ error.value = msg;
55
+ } finally {
56
+ loading.value = false;
57
+ }
58
+ }
59
+
60
+ function handleImport(): void {
61
+ if (!result.value || !confirmed.value) return;
62
+ emit('imported', result.value);
63
+ resetState();
64
+ emit('close');
65
+ }
66
+
67
+ function handleClose(): void {
68
+ resetState();
69
+ emit('close');
70
+ }
71
+
72
+ function resetState(): void {
73
+ url.value = '';
74
+ loading.value = false;
75
+ error.value = '';
76
+ result.value = null;
77
+ confirmed.value = false;
78
+ }
79
+
80
+ function onKeydown(e: KeyboardEvent): void {
81
+ if (e.key === 'Escape') handleClose();
82
+ if (e.key === 'Enter' && !result.value && canSubmit.value && !loading.value) handleFetch();
83
+ }
84
+ </script>
85
+
86
+ <template>
87
+ <Teleport to="body">
88
+ <div v-if="show" class="cpub-import-overlay" @click.self="handleClose" @keydown="onKeydown">
89
+ <div class="cpub-import-dialog" role="dialog" aria-labelledby="import-url-title" aria-modal="true">
90
+ <div class="cpub-import-header">
91
+ <h2 id="import-url-title"><i class="fa-solid fa-file-import"></i> Import from URL</h2>
92
+ <button class="cpub-import-close" aria-label="Close" @click="handleClose">
93
+ <i class="fa-solid fa-xmark"></i>
94
+ </button>
95
+ </div>
96
+
97
+ <!-- URL input -->
98
+ <div class="cpub-import-url-row">
99
+ <input
100
+ v-model="url"
101
+ type="url"
102
+ class="cpub-import-url-input"
103
+ placeholder="https://example.com/article-to-import"
104
+ aria-label="URL to import"
105
+ :disabled="loading"
106
+ @keydown.enter.prevent="handleFetch"
107
+ />
108
+ <button
109
+ class="cpub-import-fetch-btn"
110
+ :disabled="!canSubmit || loading"
111
+ @click="handleFetch"
112
+ >
113
+ <i v-if="loading" class="fa-solid fa-circle-notch fa-spin"></i>
114
+ <template v-else>Fetch</template>
115
+ </button>
116
+ </div>
117
+
118
+ <!-- Error -->
119
+ <div v-if="error" class="cpub-import-error" role="alert">
120
+ <i class="fa-solid fa-triangle-exclamation"></i> {{ error }}
121
+ </div>
122
+
123
+ <!-- Results preview -->
124
+ <div v-if="result" class="cpub-import-preview">
125
+ <div v-if="result.partial" class="cpub-import-warning">
126
+ <i class="fa-solid fa-exclamation-circle"></i>
127
+ Only partial content could be extracted. You may need to add missing sections manually.
128
+ </div>
129
+
130
+ <div class="cpub-import-preview-card">
131
+ <img
132
+ v-if="result.coverImageUrl"
133
+ :src="result.coverImageUrl"
134
+ alt=""
135
+ class="cpub-import-preview-cover"
136
+ />
137
+ <div class="cpub-import-preview-info">
138
+ <h3 class="cpub-import-preview-title">{{ result.title || 'Untitled' }}</h3>
139
+ <p v-if="result.description" class="cpub-import-preview-desc">{{ result.description }}</p>
140
+ <div class="cpub-import-preview-stats">
141
+ <span class="cpub-import-stat">{{ result.content.length }} blocks</span>
142
+ <span v-if="result.tags.length" class="cpub-import-stat">{{ result.tags.length }} tags</span>
143
+ <span v-if="result.meta.difficulty" class="cpub-import-stat">{{ result.meta.difficulty }}</span>
144
+ <span v-if="result.meta.wordCount" class="cpub-import-stat">~{{ result.meta.wordCount }} words</span>
145
+ </div>
146
+ </div>
147
+ </div>
148
+
149
+ <div v-if="result.tags.length" class="cpub-import-tags">
150
+ <span v-for="tag in result.tags.slice(0, 10)" :key="tag" class="cpub-import-tag">{{ tag }}</span>
151
+ </div>
152
+ </div>
153
+
154
+ <!-- Footer -->
155
+ <div v-if="result" class="cpub-import-footer">
156
+ <label class="cpub-import-confirm">
157
+ <input v-model="confirmed" type="checkbox" />
158
+ <span>I am the original author of this content</span>
159
+ </label>
160
+ <div class="cpub-import-actions">
161
+ <button class="cpub-import-cancel" @click="handleClose">Cancel</button>
162
+ <button
163
+ class="cpub-import-btn"
164
+ :disabled="!confirmed"
165
+ @click="handleImport"
166
+ >
167
+ <i class="fa-solid fa-file-import"></i> Import Content
168
+ </button>
169
+ </div>
170
+ </div>
171
+ </div>
172
+ </div>
173
+ </Teleport>
174
+ </template>
175
+
176
+ <style scoped>
177
+ .cpub-import-overlay {
178
+ position: fixed;
179
+ inset: 0;
180
+ z-index: 10000;
181
+ background: var(--color-surface-overlay, rgba(0, 0, 0, 0.5));
182
+ backdrop-filter: blur(4px);
183
+ display: flex;
184
+ align-items: center;
185
+ justify-content: center;
186
+ padding: 16px;
187
+ }
188
+
189
+ .cpub-import-dialog {
190
+ width: 100%;
191
+ max-width: 560px;
192
+ background: var(--surface);
193
+ border: var(--border-width-default) solid var(--border);
194
+ box-shadow: var(--shadow-xl);
195
+ display: flex;
196
+ flex-direction: column;
197
+ max-height: 80vh;
198
+ }
199
+
200
+ .cpub-import-header {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: space-between;
204
+ padding: 16px 20px;
205
+ border-bottom: var(--border-width-default) solid var(--border);
206
+ }
207
+
208
+ .cpub-import-header h2 {
209
+ font-family: var(--font-mono);
210
+ font-size: 1rem;
211
+ font-weight: 700;
212
+ text-transform: uppercase;
213
+ letter-spacing: 0.04em;
214
+ display: flex;
215
+ align-items: center;
216
+ gap: 8px;
217
+ }
218
+
219
+ .cpub-import-close {
220
+ width: 32px;
221
+ height: 32px;
222
+ border: var(--border-width-default) solid transparent;
223
+ background: none;
224
+ color: var(--text-dim);
225
+ cursor: pointer;
226
+ font-size: 14px;
227
+ display: flex;
228
+ align-items: center;
229
+ justify-content: center;
230
+ }
231
+ .cpub-import-close:hover {
232
+ background: var(--surface2);
233
+ border-color: var(--border);
234
+ }
235
+
236
+ .cpub-import-url-row {
237
+ display: flex;
238
+ gap: 0;
239
+ padding: 16px 20px;
240
+ }
241
+
242
+ .cpub-import-url-input {
243
+ flex: 1;
244
+ padding: 10px 14px;
245
+ border: var(--border-width-default) solid var(--border);
246
+ border-right: none;
247
+ background: var(--bg);
248
+ color: var(--text);
249
+ font-family: var(--font-mono);
250
+ font-size: 13px;
251
+ outline: none;
252
+ }
253
+ .cpub-import-url-input:focus {
254
+ border-color: var(--accent);
255
+ }
256
+
257
+ .cpub-import-fetch-btn {
258
+ padding: 10px 20px;
259
+ border: var(--border-width-default) solid var(--accent);
260
+ background: var(--accent);
261
+ color: var(--color-text-inverse);
262
+ font-family: var(--font-mono);
263
+ font-size: 12px;
264
+ font-weight: 600;
265
+ letter-spacing: 0.04em;
266
+ text-transform: uppercase;
267
+ cursor: pointer;
268
+ min-width: 80px;
269
+ display: flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ }
273
+ .cpub-import-fetch-btn:disabled {
274
+ opacity: 0.5;
275
+ cursor: not-allowed;
276
+ }
277
+ .cpub-import-fetch-btn:hover:not(:disabled) {
278
+ opacity: 0.85;
279
+ }
280
+
281
+ .cpub-import-error {
282
+ margin: 0 20px 12px;
283
+ padding: 10px 14px;
284
+ background: var(--red-bg, rgba(255, 80, 80, 0.08));
285
+ border: var(--border-width-default) solid var(--red, #f55);
286
+ color: var(--red, #f55);
287
+ font-size: 12px;
288
+ font-family: var(--font-mono);
289
+ display: flex;
290
+ align-items: center;
291
+ gap: 8px;
292
+ }
293
+
294
+ .cpub-import-preview {
295
+ flex: 1;
296
+ overflow-y: auto;
297
+ padding: 0 20px 16px;
298
+ }
299
+
300
+ .cpub-import-warning {
301
+ padding: 10px 14px;
302
+ background: var(--yellow-bg, rgba(255, 200, 50, 0.08));
303
+ border: var(--border-width-default) solid var(--yellow, #fc3);
304
+ color: var(--yellow, #fc3);
305
+ font-size: 12px;
306
+ display: flex;
307
+ align-items: center;
308
+ gap: 8px;
309
+ margin-bottom: 12px;
310
+ }
311
+
312
+ .cpub-import-preview-card {
313
+ display: flex;
314
+ gap: 14px;
315
+ padding: 14px;
316
+ border: var(--border-width-default) solid var(--border);
317
+ background: var(--bg);
318
+ }
319
+
320
+ .cpub-import-preview-cover {
321
+ width: 100px;
322
+ height: 72px;
323
+ object-fit: cover;
324
+ border: var(--border-width-default) solid var(--border);
325
+ flex-shrink: 0;
326
+ }
327
+
328
+ .cpub-import-preview-info {
329
+ flex: 1;
330
+ min-width: 0;
331
+ }
332
+
333
+ .cpub-import-preview-title {
334
+ font-size: 14px;
335
+ font-weight: 700;
336
+ margin-bottom: 4px;
337
+ overflow: hidden;
338
+ text-overflow: ellipsis;
339
+ white-space: nowrap;
340
+ }
341
+
342
+ .cpub-import-preview-desc {
343
+ font-size: 12px;
344
+ color: var(--text-dim);
345
+ margin-bottom: 8px;
346
+ display: -webkit-box;
347
+ -webkit-line-clamp: 2;
348
+ -webkit-box-orient: vertical;
349
+ overflow: hidden;
350
+ }
351
+
352
+ .cpub-import-preview-stats {
353
+ display: flex;
354
+ gap: 10px;
355
+ flex-wrap: wrap;
356
+ }
357
+
358
+ .cpub-import-stat {
359
+ font-family: var(--font-mono);
360
+ font-size: 10px;
361
+ color: var(--text-faint);
362
+ padding: 2px 6px;
363
+ background: var(--surface2);
364
+ }
365
+
366
+ .cpub-import-tags {
367
+ display: flex;
368
+ gap: 4px;
369
+ flex-wrap: wrap;
370
+ margin-top: 10px;
371
+ }
372
+
373
+ .cpub-import-tag {
374
+ padding: 2px 8px;
375
+ background: var(--surface2);
376
+ border: var(--border-width-default) solid var(--border);
377
+ font-family: var(--font-mono);
378
+ font-size: 10px;
379
+ color: var(--text-dim);
380
+ }
381
+
382
+ .cpub-import-footer {
383
+ padding: 14px 20px;
384
+ border-top: var(--border-width-default) solid var(--border);
385
+ display: flex;
386
+ flex-direction: column;
387
+ gap: 12px;
388
+ }
389
+
390
+ .cpub-import-confirm {
391
+ display: flex;
392
+ align-items: center;
393
+ gap: 8px;
394
+ font-size: 12px;
395
+ color: var(--text-dim);
396
+ cursor: pointer;
397
+ }
398
+ .cpub-import-confirm input {
399
+ accent-color: var(--accent);
400
+ }
401
+
402
+ .cpub-import-actions {
403
+ display: flex;
404
+ gap: 8px;
405
+ justify-content: flex-end;
406
+ }
407
+
408
+ .cpub-import-cancel {
409
+ padding: 7px 14px;
410
+ border: var(--border-width-default) solid var(--border);
411
+ background: var(--surface);
412
+ color: var(--text-dim);
413
+ font-size: 12px;
414
+ cursor: pointer;
415
+ }
416
+ .cpub-import-cancel:hover {
417
+ background: var(--surface2);
418
+ }
419
+
420
+ .cpub-import-btn {
421
+ padding: 7px 16px;
422
+ border: var(--border-width-default) solid var(--accent);
423
+ background: var(--accent);
424
+ color: var(--color-text-inverse);
425
+ font-size: 12px;
426
+ font-weight: 600;
427
+ cursor: pointer;
428
+ display: flex;
429
+ align-items: center;
430
+ gap: 6px;
431
+ }
432
+ .cpub-import-btn:disabled {
433
+ opacity: 0.5;
434
+ cursor: not-allowed;
435
+ }
436
+ .cpub-import-btn:hover:not(:disabled) {
437
+ opacity: 0.85;
438
+ }
439
+
440
+ @media (max-width: 640px) {
441
+ .cpub-import-dialog {
442
+ max-width: 100%;
443
+ max-height: 90vh;
444
+ }
445
+ .cpub-import-preview-cover {
446
+ display: none;
447
+ }
448
+ }
449
+ </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/layer",
3
- "version": "0.3.35",
3
+ "version": "0.3.37",
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"
@@ -46,13 +51,13 @@
46
51
  "zod": "^4.3.6",
47
52
  "@commonpub/auth": "0.5.0",
48
53
  "@commonpub/config": "0.7.1",
49
- "@commonpub/docs": "0.5.2",
50
54
  "@commonpub/editor": "0.5.0",
51
55
  "@commonpub/learning": "0.5.0",
56
+ "@commonpub/docs": "0.5.2",
52
57
  "@commonpub/protocol": "0.9.5",
53
- "@commonpub/server": "2.19.1",
54
- "@commonpub/ui": "0.7.1",
55
- "@commonpub/schema": "0.8.13"
58
+ "@commonpub/schema": "0.8.13",
59
+ "@commonpub/server": "2.20.1",
60
+ "@commonpub/ui": "0.7.1"
56
61
  },
57
62
  "devDependencies": {
58
63
  "@testing-library/jest-dom": "^6.9.1",
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { Component } from 'vue';
3
+ import type { BlockTuple } from '@commonpub/editor';
3
4
  definePageMeta({ layout: false, middleware: 'auth' });
4
5
 
5
6
  const route = useRoute();
@@ -209,6 +210,67 @@ async function handleMarkdownImport(md: string, importMode: 'append' | 'replace'
209
210
  await importMarkdown(md, importMode);
210
211
  isDirty.value = true;
211
212
  }
213
+
214
+ // --- URL import ---
215
+ const showUrlImport = ref(false);
216
+ const urlImporting = ref(false);
217
+
218
+ interface ImportedContent {
219
+ title: string;
220
+ description: string;
221
+ coverImageUrl: string | null;
222
+ content: BlockTuple[];
223
+ tags: string[];
224
+ partial: boolean;
225
+ meta: Record<string, unknown>;
226
+ }
227
+
228
+ async function handleUrlImport(result: ImportedContent): Promise<void> {
229
+ urlImporting.value = true;
230
+ try {
231
+ // Populate title if empty
232
+ if (!title.value && result.title) {
233
+ title.value = result.title.slice(0, 255);
234
+ }
235
+
236
+ // Populate metadata — sanitize values to match schema constraints
237
+ if (result.description && !metadata.value.description) {
238
+ metadata.value = { ...metadata.value, description: result.description.slice(0, 2000) };
239
+ }
240
+ if (result.coverImageUrl && !metadata.value.coverImageUrl) {
241
+ try {
242
+ new URL(result.coverImageUrl);
243
+ metadata.value = { ...metadata.value, coverImageUrl: result.coverImageUrl };
244
+ } catch { /* skip invalid URL */ }
245
+ }
246
+ if (result.tags.length && (!Array.isArray(metadata.value.tags) || !metadata.value.tags.length)) {
247
+ const safeTags = result.tags
248
+ .filter(t => typeof t === 'string' && t.length > 0)
249
+ .map(t => t.slice(0, 64))
250
+ .slice(0, 20);
251
+ metadata.value = { ...metadata.value, tags: safeTags };
252
+ }
253
+ const VALID_DIFFICULTIES = ['beginner', 'intermediate', 'advanced'];
254
+ if (result.meta.difficulty && !metadata.value.difficulty) {
255
+ const diff = String(result.meta.difficulty).toLowerCase();
256
+ if (VALID_DIFFICULTIES.includes(diff)) {
257
+ metadata.value = { ...metadata.value, difficulty: diff };
258
+ }
259
+ }
260
+
261
+ // Insert blocks
262
+ blockEditor.clearBlocks();
263
+ let insertAt = 0;
264
+ for (const [type, content] of result.content) {
265
+ blockEditor.addBlock(type, content as Record<string, unknown>, insertAt);
266
+ insertAt++;
267
+ }
268
+
269
+ isDirty.value = true;
270
+ } finally {
271
+ urlImporting.value = false;
272
+ }
273
+ }
212
274
  </script>
213
275
 
214
276
  <template>
@@ -229,6 +291,7 @@ async function handleMarkdownImport(md: string, importMode: 'append' | 'replace'
229
291
  <div v-else class="cpub-editor-layout">
230
292
  <PublishErrorsModal :errors="publishErrors" :show="showPublishErrors" @dismiss="dismissPublishErrors" />
231
293
  <EditorsMarkdownImportDialog :show="showImportDialog" @close="showImportDialog = false" @import="handleMarkdownImport" />
294
+ <ImportUrlModal :show="showUrlImport" @close="showUrlImport = false" @imported="handleUrlImport" />
232
295
  <!-- Top bar -->
233
296
  <header class="cpub-editor-topbar">
234
297
  <NuxtLink to="/" class="cpub-editor-logo" aria-label="Home">
@@ -264,8 +327,11 @@ async function handleMarkdownImport(md: string, importMode: 'append' | 'replace'
264
327
  </div>
265
328
  <div class="cpub-topbar-spacer" />
266
329
  <div class="cpub-topbar-actions">
330
+ <button class="cpub-topbar-btn cpub-topbar-btn-import" :disabled="urlImporting" @click="showUrlImport = true" title="Import from URL">
331
+ <i class="fa-solid fa-link"></i> <span class="cpub-import-label">Import URL</span>
332
+ </button>
267
333
  <button class="cpub-topbar-btn cpub-topbar-btn-import" :disabled="importing" @click="showImportDialog = true" title="Import Markdown">
268
- <i class="fa-brands fa-markdown"></i> <span class="cpub-import-label">Import</span>
334
+ <i class="fa-brands fa-markdown"></i> <span class="cpub-import-label">Markdown</span>
269
335
  </button>
270
336
  <button class="cpub-topbar-btn" :disabled="saving || !title" @click="silentSave">
271
337
  {{ saving ? 'Saving...' : 'Save Draft' }}
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,
@@ -0,0 +1,34 @@
1
+ import { importFromUrl } from '@commonpub/server/import';
2
+ import type { ImportResult } from '@commonpub/server/import';
3
+ import { z } from 'zod';
4
+
5
+ const importBodySchema = z.object({
6
+ url: z.string().url(),
7
+ });
8
+
9
+ export default defineEventHandler(async (event): Promise<ImportResult> => {
10
+ requireAuth(event);
11
+
12
+ const { url } = await parseBody(event, importBodySchema);
13
+
14
+ try {
15
+ return await importFromUrl(url);
16
+ } catch (err: unknown) {
17
+ const message = err instanceof Error ? err.message : 'Import failed';
18
+
19
+ if (message.includes('private') || message.includes('reserved')) {
20
+ throw createError({ statusCode: 400, statusMessage: message });
21
+ }
22
+ if (message === 'Invalid URL' || message.includes('must use HTTP')) {
23
+ throw createError({ statusCode: 400, statusMessage: message });
24
+ }
25
+ if (message.includes('HTTP ')) {
26
+ throw createError({ statusCode: 502, statusMessage: `Failed to fetch URL: ${message}` });
27
+ }
28
+ if (message.includes('Too many redirects') || message.includes('too large')) {
29
+ throw createError({ statusCode: 400, statusMessage: message });
30
+ }
31
+
32
+ throw createError({ statusCode: 500, statusMessage: 'Content import failed' });
33
+ }
34
+ });
@@ -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
- });