Social Annotations in the Atmosphere
15
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: show url on annotation component

+137 -525
+7 -5
landing/landing.ts
··· 12 12 const loadMoreContainer = document.getElementById('load-more'); 13 13 const loadMoreBtn = document.getElementById('load-more-btn'); 14 14 15 + // Determine proxy base URL for annotation source links 16 + const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; 17 + const proxyBaseUrl = isLocal ? 'http://127.0.0.1:8081' : 'https://sure.seams.so'; 18 + 15 19 // Fetch actor profile (avatar) from Bluesky 16 20 async function fetchActorProfile(did: string) { 17 21 if (avatarCache.has(did)) { ··· 84 88 const card = document.createElement('seams-annotation-card') as SeamsAnnotationCard; 85 89 card.annotation = annotation; 86 90 card.comments = []; // No comments on landing for now 91 + card.showSourceInfo = true; 92 + card.proxyBaseUrl = proxyBaseUrl; 87 93 88 94 fragment.appendChild(card); 89 95 } ··· 129 135 url = 'https://' + url; 130 136 } 131 137 132 - // Determine proxy base URL (port 8081 is the static server that serves the proxy client) 133 - const isLocal = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; 134 - const proxyBase = isLocal ? 'http://127.0.0.1:8081' : 'https://sure.seams.so'; 135 - 136 138 // Redirect to hash-based proxy URL 137 - window.location.href = `${proxyBase}/#${url}`; 139 + window.location.href = `${proxyBaseUrl}/#${url}`; 138 140 }); 139 141 }
+2 -2
package.json
··· 10 10 "build:landing": "vite build --config vite.landing.config.ts", 11 11 "build:proxy": "vite build --config vite.proxy.config.ts && vite build --config vite.proxy-inject.config.ts", 12 12 "dev:proxy": "NODE_ENV=development VITE_BACKEND_URL=https://seams.so pnpm build:proxy && concurrently \"cd proxy/cors-proxy && npm run dev\" \"npx serve proxy/dist -l 8081\"", 13 - "dev:server": "NODE_ENV=development pnpm run build:landing && cd server && air", 14 - "dev:all": "NODE_ENV=development VITE_BACKEND_URL=http://127.0.0.1:8080 pnpm build:proxy && concurrently --names \"server,cors,proxy\" -c \"green,yellow,blue\" \"cd server && air\" \"cd proxy/cors-proxy && npm run dev\" \"npx serve proxy/dist -l 8081\"", 13 + "dev:server": "NODE_ENV=development BACKEND_URL=http://localhost:8080 pnpm run build:landing && cd server && air", 14 + "dev:all": "NODE_ENV=development VITE_BACKEND_URL=http://127.0.0.1:8080 pnpm build:proxy && concurrently --names \"server,proxy\" -c \"green,yellow,blue\" \"pnpm dev:server\" \"pnpm dev:proxy\"", 15 15 "test": "pnpm --filter @seams/core test", 16 16 "test:coverage": "pnpm --filter @seams/core test:coverage", 17 17 "test:watch": "pnpm --filter @seams/core test:watch",
+55 -7
packages/core/src/components/annotation-card.ts
··· 1 1 import { Annotation } from '../types'; 2 2 import { formatRelativeTime } from '../utils/date'; 3 3 import { escapeHtml } from '../utils/sanitize'; 4 + import { extractDomain } from '../utils/url'; 4 5 5 6 const STYLES = ` 6 7 :host { ··· 79 80 text-decoration: underline; 80 81 } 81 82 83 + 84 + 82 85 .annotation-source { 83 - color: #1a1a1a; 86 + display: inline-flex; 87 + align-items: center; 88 + gap: 4px; 89 + color: #666; 84 90 text-decoration: none; 85 - font-weight: 500; 91 + font-size: 12px; 92 + } 93 + 94 + .annotation-source:hover { 95 + text-decoration: underline; 86 96 } 87 97 `; 88 98 89 99 export class SeamsAnnotationCard extends HTMLElement { 90 100 private _annotation: Annotation | null = null; 101 + private _showSourceInfo: boolean = false; 102 + private _proxyBaseUrl: string = ''; 91 103 92 104 static get observedAttributes() { 93 105 return ['annotation']; ··· 111 123 return this._annotation; 112 124 } 113 125 126 + /** 127 + * When true, displays the source domain and link arrow. 128 + * Set to true for landing page, false (default) for sidebar. 129 + */ 130 + set showSourceInfo(val: boolean) { 131 + this._showSourceInfo = val; 132 + this.render(); 133 + } 134 + 135 + get showSourceInfo() { 136 + return this._showSourceInfo; 137 + } 138 + 139 + /** 140 + * Base URL for the proxy (e.g., 'https://sure.seams.so'). 141 + * When set, source links will point to proxyBaseUrl/#<url>. 142 + * When empty, links point directly to the source URL. 143 + */ 144 + set proxyBaseUrl(val: string) { 145 + this._proxyBaseUrl = val; 146 + this.render(); 147 + } 148 + 149 + get proxyBaseUrl() { 150 + return this._proxyBaseUrl; 151 + } 152 + 114 153 private render() { 115 154 if (!this.shadowRoot) return; 116 155 if (!this._annotation) { ··· 126 165 const authorHandle = ann.author?.handle || (authorDid.includes(':') ? authorDid.split(':').pop() : authorDid); 127 166 const avatarSrc = ann.author?.avatar || `https://api.dicebear.com/7.x/initials/svg?seed=${encodeURIComponent(authorHandle || authorDid)}`; 128 167 168 + // Generate source info HTML only when showSourceInfo is true 169 + let sourceInfoHtml = ''; 170 + if (this._showSourceInfo && ann.value.target.url) { 171 + const domain = extractDomain(ann.value.target.url); 172 + const linkUrl = this._proxyBaseUrl 173 + ? `${this._proxyBaseUrl}/#${ann.value.target.url}` 174 + : ann.value.target.url; 175 + 176 + sourceInfoHtml = ` 177 + <a href="${escapeHtml(linkUrl)}" target="_blank" class="annotation-source">${escapeHtml(domain)} ↗</a> 178 + `; 179 + } 180 + 129 181 const html = ` 130 182 <style>${STYLES}</style> 131 183 <article class="annotation-card" data-uri="${ann.uri}"> ··· 140 192 </a> 141 193 </div> 142 194 <span>${formatRelativeTime(ann.value.createdAt)}</span> 143 - ${ann.value.target.url ? ` 144 - <a href="${escapeHtml(ann.value.target.url)}" target="_blank" class="annotation-source"> 145 - 146 - </a> 147 - ` : ''} 195 + ${sourceInfoHtml} 148 196 </div> 149 197 </article> 150 198 `;
-388
packages/core/src/sidebar/__tests__/rendering.test.ts
··· 1 - import { describe, it, expect, beforeEach } from 'vitest'; 2 - import { buildCommentThread, renderAnnotationCard } from '../rendering'; 3 - import { UIState } from '../ui-state'; 4 - import type { Annotation } from '../../types'; 5 - import type { Comment } from '../../pds'; 6 - 7 - describe('buildCommentThread', () => { 8 - let uiState: UIState; 9 - 10 - beforeEach(() => { 11 - uiState = new UIState(); 12 - }); 13 - 14 - it('returns empty string when no replies exist', () => { 15 - const comments: Comment[] = []; 16 - const result = buildCommentThread('parent:uri', comments, uiState); 17 - expect(result).toBe(''); 18 - }); 19 - 20 - it('renders replies to a parent comment', () => { 21 - const comments: Comment[] = [ 22 - { 23 - uri: 'reply:1', 24 - subject: 'annotation:1', 25 - plaintext: 'First reply', 26 - createdAt: '2024-01-01T12:00:00Z', 27 - reply: { parent: 'parent:uri' }, 28 - }, 29 - ]; 30 - 31 - const result = buildCommentThread('parent:uri', comments, uiState); 32 - 33 - expect(result).toContain('comment-thread'); 34 - expect(result).toContain('First reply'); 35 - expect(result).toContain('1 reply'); 36 - }); 37 - 38 - it('renders multiple replies with correct plural', () => { 39 - const comments: Comment[] = [ 40 - { 41 - uri: 'reply:1', 42 - subject: 'ann:1', 43 - plaintext: 'Reply 1', 44 - createdAt: '2024-01-01T12:00:00Z', 45 - reply: { parent: 'parent:uri' }, 46 - }, 47 - { 48 - uri: 'reply:2', 49 - subject: 'ann:1', 50 - plaintext: 'Reply 2', 51 - createdAt: '2024-01-01T12:00:00Z', 52 - reply: { parent: 'parent:uri' }, 53 - }, 54 - ]; 55 - 56 - const result = buildCommentThread('parent:uri', comments, uiState); 57 - 58 - expect(result).toContain('2 replies'); 59 - }); 60 - 61 - it('shows collapsed state indicator', () => { 62 - const comments: Comment[] = [ 63 - { 64 - uri: 'reply:1', 65 - subject: 'ann:1', 66 - plaintext: 'Reply', 67 - createdAt: '2024-01-01T12:00:00Z', 68 - reply: { parent: 'parent:uri' }, 69 - }, 70 - ]; 71 - 72 - uiState.toggleThreadCollapsed('parent:uri'); 73 - const result = buildCommentThread('parent:uri', comments, uiState); 74 - 75 - expect(result).toContain('▸'); // Collapsed arrow 76 - expect(result).not.toContain('thread-children'); // Children hidden 77 - }); 78 - 79 - it('shows expanded state with children', () => { 80 - const comments: Comment[] = [ 81 - { 82 - uri: 'reply:1', 83 - subject: 'ann:1', 84 - plaintext: 'Reply content', 85 - createdAt: '2024-01-01T12:00:00Z', 86 - reply: { parent: 'parent:uri' }, 87 - }, 88 - ]; 89 - 90 - const result = buildCommentThread('parent:uri', comments, uiState); 91 - 92 - expect(result).toContain('▾'); // Expanded arrow 93 - expect(result).toContain('thread-children'); 94 - expect(result).toContain('Reply content'); 95 - }); 96 - 97 - it('renders nested reply form when active', () => { 98 - const comments: Comment[] = [ 99 - { 100 - uri: 'reply:1', 101 - subject: 'ann:1', 102 - plaintext: 'Reply', 103 - createdAt: '2024-01-01T12:00:00Z', 104 - reply: { parent: 'parent:uri' }, 105 - }, 106 - ]; 107 - 108 - uiState.showReplyForm('reply:1'); 109 - const result = buildCommentThread('parent:uri', comments, uiState); 110 - 111 - expect(result).toContain('reply-form'); 112 - expect(result).toContain('data-parent="reply:1"'); 113 - expect(result).toContain('Write a reply...'); 114 - }); 115 - 116 - it('recursively renders nested threads', () => { 117 - const comments: Comment[] = [ 118 - { 119 - uri: 'reply:1', 120 - subject: 'ann:1', 121 - plaintext: 'First level reply', 122 - createdAt: '2024-01-01T12:00:00Z', 123 - reply: { parent: 'parent:uri' }, 124 - }, 125 - { 126 - uri: 'reply:2', 127 - subject: 'ann:1', 128 - plaintext: 'Second level reply', 129 - createdAt: '2024-01-01T12:00:00Z', 130 - reply: { parent: 'reply:1' }, 131 - }, 132 - ]; 133 - 134 - const result = buildCommentThread('parent:uri', comments, uiState); 135 - 136 - expect(result).toContain('First level reply'); 137 - expect(result).toContain('Second level reply'); 138 - expect(result).toContain('nested'); // Nested class for second level 139 - }); 140 - 141 - it('adds single-child class when only one reply', () => { 142 - const comments: Comment[] = [ 143 - { 144 - uri: 'reply:1', 145 - subject: 'ann:1', 146 - plaintext: 'Only reply', 147 - createdAt: '2024-01-01T12:00:00Z', 148 - reply: { parent: 'parent:uri' }, 149 - }, 150 - ]; 151 - 152 - const result = buildCommentThread('parent:uri', comments, uiState); 153 - 154 - expect(result).toContain('single-child'); 155 - }); 156 - }); 157 - 158 - describe('renderAnnotationCard', () => { 159 - let uiState: UIState; 160 - 161 - beforeEach(() => { 162 - uiState = new UIState(); 163 - }); 164 - 165 - const createAnnotation = (overrides: Partial<Annotation> = {}): Annotation => ({ 166 - uri: 'ann:1', 167 - cid: 'cid1', 168 - value: { 169 - target: { 170 - url: 'https://example.com', 171 - selector: [ 172 - { 173 - $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 174 - exact: 'Selected text', 175 - }, 176 - ], 177 - }, 178 - body: 'Annotation body', 179 - createdAt: '2024-01-01T12:00:00Z', 180 - }, 181 - ...overrides, 182 - }); 183 - 184 - it('renders annotation with quote', () => { 185 - const ann = createAnnotation(); 186 - const result = renderAnnotationCard(ann, [], uiState); 187 - 188 - expect(result).toContain('annotation-card'); 189 - expect(result).toContain('<blockquote>Selected text</blockquote>'); 190 - }); 191 - 192 - it('renders annotation body', () => { 193 - const ann = createAnnotation(); 194 - const result = renderAnnotationCard(ann, [], uiState); 195 - 196 - expect(result).toContain('<p>Annotation body</p>'); 197 - }); 198 - 199 - it('handles annotation without selector', () => { 200 - const ann = createAnnotation({ 201 - value: { 202 - target: { url: 'https://example.com' }, 203 - body: 'Body only', 204 - createdAt: '2024-01-01T12:00:00Z', 205 - }, 206 - }); 207 - 208 - const result = renderAnnotationCard(ann, [], uiState); 209 - 210 - expect(result).not.toContain('<blockquote>'); 211 - expect(result).toContain('Body only'); 212 - }); 213 - 214 - it('handles annotation without body', () => { 215 - const ann = createAnnotation({ 216 - value: { 217 - target: { 218 - url: 'https://example.com', 219 - selector: [ 220 - { 221 - $type: 'community.lexicon.annotation.annotation#textQuoteSelector', 222 - exact: 'Quote only', 223 - }, 224 - ], 225 - }, 226 - body: '', 227 - createdAt: '2024-01-01T12:00:00Z', 228 - }, 229 - }); 230 - 231 - const result = renderAnnotationCard(ann, [], uiState); 232 - 233 - expect(result).toContain('<blockquote>Quote only</blockquote>'); 234 - expect(result).not.toContain('<p></p>'); 235 - }); 236 - 237 - it('renders comment count', () => { 238 - const ann = createAnnotation(); 239 - const comments: Comment[] = [ 240 - { 241 - uri: 'comment:1', 242 - subject: 'ann:1', 243 - plaintext: 'Comment 1', 244 - createdAt: '2024-01-01T12:00:00Z', 245 - }, 246 - { 247 - uri: 'comment:2', 248 - subject: 'ann:1', 249 - plaintext: 'Comment 2', 250 - createdAt: '2024-01-01T12:00:00Z', 251 - }, 252 - ]; 253 - 254 - const result = renderAnnotationCard(ann, comments, uiState); 255 - 256 - expect(result).toContain('2 comments'); 257 - }); 258 - 259 - it('renders singular comment count', () => { 260 - const ann = createAnnotation(); 261 - const comments: Comment[] = [ 262 - { 263 - uri: 'comment:1', 264 - subject: 'ann:1', 265 - plaintext: 'Only comment', 266 - createdAt: '2024-01-01T12:00:00Z', 267 - }, 268 - ]; 269 - 270 - const result = renderAnnotationCard(ann, comments, uiState); 271 - 272 - expect(result).toContain('1 comment'); 273 - expect(result).not.toContain('1 comments'); 274 - }); 275 - 276 - it('shows collapsed comments indicator', () => { 277 - const ann = createAnnotation(); 278 - const comments: Comment[] = [ 279 - { 280 - uri: 'comment:1', 281 - subject: 'ann:1', 282 - plaintext: 'Comment', 283 - createdAt: '2024-01-01T12:00:00Z', 284 - }, 285 - ]; 286 - 287 - uiState.toggleThreadCollapsed('ann:1'); 288 - const result = renderAnnotationCard(ann, comments, uiState); 289 - 290 - expect(result).toContain('▸'); 291 - expect(result).not.toContain('comments-list'); 292 - }); 293 - 294 - it('renders comments when expanded', () => { 295 - const ann = createAnnotation(); 296 - const comments: Comment[] = [ 297 - { 298 - uri: 'comment:1', 299 - subject: 'ann:1', 300 - plaintext: 'Comment content', 301 - createdAt: '2024-01-01T12:00:00Z', 302 - }, 303 - ]; 304 - 305 - const result = renderAnnotationCard(ann, comments, uiState); 306 - 307 - expect(result).toContain('comments-list'); 308 - expect(result).toContain('Comment content'); 309 - }); 310 - 311 - it('renders comment form when active', () => { 312 - const ann = createAnnotation(); 313 - 314 - uiState.showReplyForm('ann:1'); 315 - const result = renderAnnotationCard(ann, [], uiState); 316 - 317 - expect(result).toContain('comment-form'); 318 - expect(result).toContain('Write a comment...'); 319 - expect(result).toContain('save-comment-btn'); 320 - expect(result).toContain('cancel-comment-btn'); 321 - }); 322 - 323 - it('renders reply button on comments', () => { 324 - const ann = createAnnotation(); 325 - const comments: Comment[] = [ 326 - { 327 - uri: 'comment:1', 328 - subject: 'ann:1', 329 - plaintext: 'Comment', 330 - createdAt: '2024-01-01T12:00:00Z', 331 - }, 332 - ]; 333 - 334 - const result = renderAnnotationCard(ann, comments, uiState); 335 - 336 - expect(result).toContain('reply-btn'); 337 - expect(result).toContain('data-uri="comment:1"'); 338 - }); 339 - 340 - it('filters out replies from top-level comments', () => { 341 - const ann = createAnnotation(); 342 - const comments: Comment[] = [ 343 - { 344 - uri: 'comment:1', 345 - subject: 'ann:1', 346 - plaintext: 'Top level', 347 - createdAt: '2024-01-01T12:00:00Z', 348 - }, 349 - { 350 - uri: 'reply:1', 351 - subject: 'ann:1', 352 - plaintext: 'This is a reply', 353 - createdAt: '2024-01-01T12:00:00Z', 354 - reply: { parent: 'comment:1' }, 355 - }, 356 - ]; 357 - 358 - const result = renderAnnotationCard(ann, comments, uiState); 359 - 360 - // Top level should show "1 comment" not "2 comments" 361 - expect(result).toContain('1 comment'); 362 - }); 363 - 364 - it('renders nested threads for comments with replies', () => { 365 - const ann = createAnnotation(); 366 - const comments: Comment[] = [ 367 - { 368 - uri: 'comment:1', 369 - subject: 'ann:1', 370 - plaintext: 'Parent comment', 371 - createdAt: '2024-01-01T12:00:00Z', 372 - }, 373 - { 374 - uri: 'reply:1', 375 - subject: 'ann:1', 376 - plaintext: 'Reply to parent', 377 - createdAt: '2024-01-01T12:00:00Z', 378 - reply: { parent: 'comment:1' }, 379 - }, 380 - ]; 381 - 382 - const result = renderAnnotationCard(ann, comments, uiState); 383 - 384 - expect(result).toContain('Parent comment'); 385 - expect(result).toContain('Reply to parent'); 386 - expect(result).toContain('comment-thread'); 387 - }); 388 - });
-2
packages/core/src/sidebar/index.ts
··· 5 5 import { PDSClient as PDSClientImpl } from '../pds'; 6 6 import type { Annotation } from '../types'; 7 7 import { UIState } from './ui-state'; 8 - import { renderAnnotationCard } from './rendering'; 9 8 import { normalizeUrl } from './utils'; 10 9 import { escapeHtml } from '../utils/sanitize'; 11 10 ··· 540 539 } 541 540 542 541 export { UIState } from './ui-state'; 543 - export { renderAnnotationCard, buildCommentThread } from './rendering'; 544 542 export { normalizeUrl } from './utils';
-120
packages/core/src/sidebar/rendering.ts
··· 1 - import type { Annotation } from '../types'; 2 - import type { Comment } from '../pds'; 3 - import type { UIState } from './ui-state'; 4 - 5 - export function buildCommentThread( 6 - parentUri: string, 7 - allComments: Comment[], 8 - uiState: UIState, 9 - isNested: boolean = false 10 - ): string { 11 - const replies = allComments.filter(c => c.reply?.parent === parentUri); 12 - if (replies.length === 0) return ''; 13 - 14 - const isCollapsed = uiState.isThreadCollapsed(parentUri); 15 - 16 - return ` 17 - <div class="comment-thread ${isNested ? 'nested' : ''}"> 18 - <button class="thread-toggle-btn" data-uri="${parentUri}"> 19 - ${isCollapsed ? '▸' : '▾'} ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'} 20 - </button> 21 - ${!isCollapsed ? ` 22 - <div class="thread-children ${replies.length === 1 ? 'single-child' : ''}"> 23 - ${replies.map(comment => { 24 - const hasReplies = allComments.some(c => c.reply?.parent === comment.uri); 25 - const isReplyFormActive = uiState.isReplyFormActive(comment.uri!); 26 - 27 - return ` 28 - <div class="comment" data-uri="${comment.uri}"> 29 - <div class="comment-content"> 30 - <div class="comment-text">${comment.plaintext}</div> 31 - <div class="comment-meta"> 32 - <small>${new Date(comment.createdAt).toLocaleString()}</small> 33 - <button class="reply-btn" data-uri="${comment.uri}">Reply</button> 34 - </div> 35 - </div> 36 - ${isReplyFormActive ? ` 37 - <div class="reply-form" data-parent="${comment.uri}"> 38 - <textarea class="reply-input" placeholder="Write a reply..."></textarea> 39 - <div class="reply-actions"> 40 - <button class="save-reply-btn">Post</button> 41 - <button class="cancel-reply-btn">Cancel</button> 42 - </div> 43 - </div> 44 - ` : ''} 45 - ${hasReplies ? buildCommentThread(comment.uri!, allComments, uiState, true) : ''} 46 - </div> 47 - `; 48 - }).join('')} 49 - </div> 50 - ` : ''} 51 - </div> 52 - `; 53 - } 54 - 55 - export function renderAnnotationCard( 56 - ann: Annotation, 57 - allComments: Comment[], 58 - uiState: UIState 59 - ): string { 60 - const quote = ann.value.target.selector?.find((s: any) => s.$type === 'community.lexicon.annotation.annotation#textQuoteSelector'); 61 - const text = quote?.exact || ''; 62 - const comments = allComments.filter(c => c.subject === ann.uri && !c.reply); 63 - const isCommentsCollapsed = uiState.isThreadCollapsed(ann.uri!); 64 - const isCommentFormActive = uiState.isReplyFormActive(ann.uri!); 65 - 66 - return ` 67 - <div class="annotation-card" data-uri="${ann.uri}"> 68 - ${text ? `<blockquote>${text}</blockquote>` : ''} 69 - ${ann.value.body ? `<p>${ann.value.body}</p>` : ''} 70 - <div class="annotation-meta"> 71 - <small>${new Date(ann.value.createdAt).toLocaleString()}</small> 72 - </div> 73 - <div class="comments-section"> 74 - <div class="comments-header"> 75 - <button class="toggle-comments-btn" data-uri="${ann.uri}"> 76 - ${isCommentsCollapsed ? '▸' : '▾'} ${comments.length} comment${comments.length !== 1 ? 's' : ''} 77 - </button> 78 - <button class="add-comment-btn" data-uri="${ann.uri}">Add comment</button> 79 - </div> 80 - ${!isCommentsCollapsed ? ` 81 - <div class="comments-list"> 82 - ${isCommentFormActive ? ` 83 - <div class="comment-form" data-subject="${ann.uri}"> 84 - <textarea class="comment-input" placeholder="Write a comment..."></textarea> 85 - <div class="comment-actions"> 86 - <button class="save-comment-btn">Post</button> 87 - <button class="cancel-comment-btn">Cancel</button> 88 - </div> 89 - </div> 90 - ` : ''} 91 - ${comments.map(comment => { 92 - const hasReplies = allComments.some(c => c.reply?.parent === comment.uri); 93 - 94 - return ` 95 - <div class="comment" data-uri="${comment.uri}"> 96 - <div class="comment-content"> 97 - <div class="comment-text">${comment.plaintext}</div> 98 - <div class="comment-meta"> 99 - <small>${new Date(comment.createdAt).toLocaleString()}</small> 100 - <button class="reply-btn" data-uri="${comment.uri}">Reply</button> 101 - </div> 102 - </div> 103 - ${uiState.isReplyFormActive(comment.uri!) ? ` 104 - <div class="reply-form" data-parent="${comment.uri}"> 105 - <textarea class="reply-input" placeholder="Write a reply..."></textarea> 106 - <div class="reply-actions"> 107 - <button class="save-reply-btn">Post</button> 108 - <button class="cancel-reply-btn">Cancel</button> 109 - </div> 110 - </div> 111 - ` : ''} 112 - ${hasReplies ? buildCommentThread(comment.uri!, allComments, uiState, true) : ''} 113 - </div> 114 - `;}).join('')} 115 - </div> 116 - ` : ''} 117 - </div> 118 - </div> 119 - `; 120 - }
+47 -1
packages/core/src/utils/__tests__/url.test.ts
··· 1 1 import { describe, it, expect } from 'vitest'; 2 - import { normalizeUrl } from '../url'; 2 + import { normalizeUrl, extractDomain } from '../url'; 3 3 4 4 describe('normalizeUrl', () => { 5 5 it('removes hash fragments', () => { ··· 50 50 ); 51 51 }); 52 52 }); 53 + 54 + describe('extractDomain', () => { 55 + it('extracts domain from a simple URL', () => { 56 + expect(extractDomain('https://example.com/page')).toBe('example.com'); 57 + }); 58 + 59 + it('removes www prefix', () => { 60 + expect(extractDomain('https://www.example.com/page')).toBe('example.com'); 61 + }); 62 + 63 + it('preserves subdomains other than www', () => { 64 + expect(extractDomain('https://blog.example.com/post')).toBe('blog.example.com'); 65 + }); 66 + 67 + it('handles complex paths without including them', () => { 68 + expect(extractDomain('https://www.nytimes.com/2024/01/15/technology/ai.html')).toBe('nytimes.com'); 69 + }); 70 + 71 + it('handles URLs with ports', () => { 72 + expect(extractDomain('https://example.com:8080/page')).toBe('example.com'); 73 + }); 74 + 75 + it('handles URLs with query strings', () => { 76 + expect(extractDomain('https://example.com/search?q=test')).toBe('example.com'); 77 + }); 78 + 79 + it('handles URLs with hash fragments', () => { 80 + expect(extractDomain('https://example.com/page#section')).toBe('example.com'); 81 + }); 82 + 83 + it('returns empty string for invalid URLs', () => { 84 + expect(extractDomain('not-a-url')).toBe(''); 85 + }); 86 + 87 + it('returns empty string for empty input', () => { 88 + expect(extractDomain('')).toBe(''); 89 + }); 90 + 91 + it('handles multiple subdomains', () => { 92 + expect(extractDomain('https://api.v2.example.com/endpoint')).toBe('api.v2.example.com'); 93 + }); 94 + 95 + it('removes only www prefix, not other w-prefixed subdomains', () => { 96 + expect(extractDomain('https://web.example.com/page')).toBe('web.example.com'); 97 + }); 98 + });
+26
packages/core/src/utils/url.ts
··· 16 16 return url; 17 17 } 18 18 } 19 + 20 + /** 21 + * Extracts the domain from a URL, removing 'www.' prefix if present. 22 + * Returns only the hostname (no path, query, or port). 23 + * 24 + * @example 25 + * extractDomain('https://www.example.com/page') // 'example.com' 26 + * extractDomain('https://blog.example.com/post') // 'blog.example.com' 27 + */ 28 + export function extractDomain(url: string): string { 29 + if (!url) return ''; 30 + 31 + try { 32 + const parsed = new URL(url); 33 + let hostname = parsed.hostname; 34 + 35 + // Remove 'www.' prefix if present 36 + if (hostname.startsWith('www.')) { 37 + hostname = hostname.slice(4); 38 + } 39 + 40 + return hostname; 41 + } catch { 42 + return ''; 43 + } 44 + }