Attic is a cozy space with lofty ambitions. attic.social
11
fork

Configure Feed

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

bookmark rss feed

+206 -9
+1
src/css/base/properties.css
··· 26 26 --button-border: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 255 255)" d="M10 90H5V10h5V5h80v5h5v80h-5v5H10z"/><path d="M5 5h5v5H5zM0 10h5v80H0zM10 0h80v5H10zM10 95h80v5H10zM90 5h5v5h-5zM5 90h5v5H5zM90 90h5v5h-5zM95 10h5v80h-5z"/></svg>'); 27 27 --button-border-hover: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 190 50)" d="M10 90H5V10h5V5h80v5h5v80h-5v5H10z"/><path d="M5 5h5v5H5zM0 10h5v80H0zM10 0h80v5H10zM10 95h80v5H10zM90 5h5v5h-5zM5 90h5v5H5zM90 90h5v5h-5zM95 10h5v80h-5z"/></svg>'); 28 28 --button-border-danger: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(255 255 255)" d="M10 90H5V10h5V5h80v5h5v80h-5v5H10z"/><path fill="rgb(222 34 68)" d="M5 5h5v5H5zM0 10h5v80H0zM10 0h80v5H10zM10 95h80v5H10zM90 5h5v5h-5zM5 90h5v5H5zM90 90h5v5h-5zM95 10h5v80h-5z"/></svg>'); 29 + --button-rss: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><path fill="rgb(238 128 47)" d="M10 90H5V10h5V5h80v5h5v80h-5v5H10z"/><path d="M5 5h5v5H5zM0 10h5v80H0zM10 0h80v5H10zM10 95h80v5H10zM90 5h5v5h-5zM5 90h5v5H5zM90 90h5v5h-5zM95 10h5v80h-5z"/></svg>'); 29 30 }
+1 -1
src/css/base/typography.css
··· 13 13 font-size: var(--font-size-2); 14 14 } 15 15 16 - a:where([href]) { 16 + a:where([href]:not([class])) { 17 17 --anchor-underline-color: oklch(from currentColor l c h / 0.6); 18 18 --anchor-underline-offset: 0.2em; 19 19 --anchor-underline-thickness: 2px;
+5 -1
src/css/components/bookmark.css
··· 9 9 flex-wrap: wrap; 10 10 justify-content: space-between; 11 11 12 - & button { 12 + & > button { 13 + margin-block: -9px; 14 + } 15 + 16 + & > div:has(button, .Button) { 13 17 margin-block: -9px; 14 18 } 15 19 }
+24 -2
src/css/components/button.css
··· 1 + .Button, 1 2 button[type] { 3 + --block-size: calc(var(--font-size-button) + 30px); 2 4 border: 15px solid transparent; 3 5 border-image: var(--button-border) 15 fill stretch; 4 - block-size: calc(var(--font-size-button) + 30px); 6 + block-size: var(--block-size); 5 7 color: rgb(var(--color-black)); 8 + display: block; 6 9 font-family: var(--font-family-2); 7 10 font-size: var(--font-size-button); 8 11 font-weight: 400; 9 12 inline-size: fit-content; 10 13 line-height: 1; 14 + min-inline-size: var(--block-size); 11 15 padding: 0 5px; 12 - text-box: trim-both ex alphabetic; 16 + /*text-box: trim-both ex alphabetic;*/ 17 + text-decoration: none; 13 18 text-transform: uppercase; 14 19 text-shadow: 2px 2px rgb(var(--color-brown) / 0.3); 15 20 transition: border-image 100ms; ··· 36 41 block-size: calc(var(--font-size-button) + 20px); 37 42 } 38 43 } 44 + 45 + .Button-rss { 46 + position: relative; 47 + 48 + &:not(:hover) { 49 + border-image-source: var(--button-rss); 50 + } 51 + 52 + &::after { 53 + background: url("/images/rss.svg") center / 65% auto no-repeat; 54 + content: ""; 55 + display: block; 56 + inset: -15px; 57 + position: absolute; 58 + pointer-events: none; 59 + } 60 + }
+29
src/lib/escape.ts
··· 1 + const escapeEntities = new Map([ 2 + ["&", "&amp;"], 3 + ["<", "&lt;"], 4 + [">", "&gt;"], 5 + ['"', "&quot;"], 6 + ["'", "&#39;"], 7 + ]); 8 + 9 + const unescapeEntities = new Map([ 10 + ["&amp;", "&"], 11 + ["&lt;", "<"], 12 + ["&gt;", ">"], 13 + ["&quot;", '"'], 14 + ["&#39;", "'"], 15 + ]); 16 + 17 + const escapeKeys = new RegExp([...escapeEntities.keys()].join("|"), "g"); 18 + const unescapeKeys = new RegExp([...unescapeEntities.keys()].join("|"), "g"); 19 + 20 + /** Escape HTML entities */ 21 + export const escape = (str: string): string => 22 + str.replaceAll(escapeKeys, (k) => escapeEntities.get(k)!); 23 + 24 + /** Unescape HTML entities */ 25 + export const unescape = (str: string): string => 26 + str.replaceAll(unescapeKeys, (k) => unescapeEntities.get(k)!); 27 + 28 + /** Unescape and escape HTML entities */ 29 + export const reEscape = (str: string) => escape(unescape(str));
+25 -5
src/routes/bookmarks/[did=did]/+page.svelte
··· 1 1 <script lang="ts"> 2 + import { page } from "$app/state"; 2 3 import { closeDialog, openDialog } from "$lib/dialog.svelte.js"; 3 4 import type { BookmarkEntity, BookmarkLoad } from "$lib/types"; 4 5 import { onMount } from "svelte"; ··· 11 12 let nextLoad: BookmarkLoad | undefined = $derived(data.next); 12 13 let nextObserver: IntersectionObserver | undefined = $state(undefined); 13 14 let nextDisabled = $state(true); 15 + 16 + const rssURL = $derived.by(() => { 17 + const url = new URL(page.url); 18 + url.hash = ""; 19 + url.search = ""; 20 + url.pathname += "/rss"; 21 + return url; 22 + }); 14 23 15 24 const nextAttach: Attachment = (element) => { 16 25 nextObserver?.observe(element); ··· 114 123 {:else} 115 124 <title>{data.profile.displayName} - Bookmarks - Attic</title> 116 125 {/if} 126 + <link 127 + rel="alternate" 128 + type="application/atom+xml" 129 + title="{data.profile.displayName} (attic.social bookmarks)" 130 + href={rssURL.href} 131 + /> 117 132 </svelte:head> 118 133 119 134 <h1>{data.profile.displayName}</h1> ··· 226 241 <div class="Bookmarks"> 227 242 <div class="Bookmarks-header"> 228 243 <h2>Bookmarks</h2> 229 - {#if isSelf} 230 - <button type="button" onclick={() => openDialog(createDialog)}> 231 - New 232 - </button> 233 - {/if} 244 + <div class="flex flex-wrap"> 245 + {#if isSelf} 246 + <button type="button" onclick={() => openDialog(createDialog)}> 247 + New 248 + </button> 249 + {/if} 250 + <a href={rssURL.href} class="Button Button-rss" target="_blank"> 251 + <span class="visually-hidden">RSS</span> 252 + </a> 253 + </div> 234 254 </div> 235 255 {#each bookmarks as entry (entry.cid)} 236 256 <article id={entry.cid} class="Bookmark">
+113
src/routes/bookmarks/[did=did]/rss/+server.ts
··· 1 + import { resolvePDS } from "$lib/atproto"; 2 + import { reEscape } from "$lib/escape"; 3 + import type { BookmarkEntity } from "$lib/types"; 4 + import { 5 + type ActorProfileData, 6 + parseActorProfile, 7 + parseBookmark, 8 + } from "$lib/valibot"; 9 + import { Client, simpleFetchHandler } from "@atcute/client"; 10 + import { error } from "@sveltejs/kit"; 11 + import type { RequestHandler } from "./$types"; 12 + 13 + const feedTemplate = ( 14 + props: Record<string, string>, 15 + ) => (`<?xml version="1.0" encoding="UTF-8"?> 16 + <feed xmlns="http://www.w3.org/2005/Atom"> 17 + <title>${reEscape(props.title)}</title> 18 + <link rel="self" href="${props.self}"/> 19 + <link rel="alternate" href="${props.alternate}"/> 20 + <id>${props.alternate}</id> 21 + <updated>${props.updated}</updated> 22 + <author> 23 + <name>${reEscape(props.author)}</name> 24 + </author> 25 + ${props.entries} 26 + </feed> 27 + `); 28 + 29 + const entryTemplate = ( 30 + props: Record<string, string>, 31 + ) => (` <entry> 32 + <title>${reEscape(props.title)}</title> 33 + <link rel="alternate">${props.link}</link> 34 + <id>${props.id}</id> 35 + <updated>${props.updated}</updated> 36 + </entry>`); 37 + 38 + export const GET: RequestHandler = async ({ params, url }) => { 39 + const pds = await resolvePDS(params.did); 40 + if (pds === null) { 41 + error(404); 42 + } 43 + const rpc = new Client({ 44 + handler: simpleFetchHandler({ fetch, service: pds }), 45 + }); 46 + let profile: ActorProfileData; 47 + { 48 + const response = await rpc.get("com.atproto.repo.getRecord", { 49 + params: { 50 + repo: params.did, 51 + collection: "social.attic.actor.profile", 52 + rkey: "self", 53 + }, 54 + }); 55 + if (response.ok === false) { 56 + error(404); 57 + } 58 + try { 59 + profile = parseActorProfile(response.data.value); 60 + } catch { 61 + error(404); 62 + } 63 + } 64 + const bookmarks: Array<BookmarkEntity> = []; 65 + const response = await rpc.get("com.atproto.repo.listRecords", { 66 + params: { 67 + repo: params.did, 68 + collection: "social.attic.bookmark.entity", 69 + limit: 50, 70 + }, 71 + }); 72 + if (response.ok) { 73 + for (const entity of response.data.records) { 74 + try { 75 + bookmarks.push({ 76 + cid: entity.cid, 77 + uri: entity.uri, 78 + ...parseBookmark(entity.value), 79 + }); 80 + } catch { 81 + // Ignore... 82 + } 83 + } 84 + } 85 + 86 + const entries = bookmarks.map((entry) => (entryTemplate({ 87 + title: entry.title, 88 + link: entry.url, 89 + id: entry.uri, 90 + updated: new Date(entry.createdAt).toISOString(), 91 + }))); 92 + 93 + url.hash = ""; 94 + url.search = ""; 95 + const self = url.href; 96 + url.pathname = url.pathname.replace(/\/rss\/?$/, ""); 97 + const alternate = url.href; 98 + 99 + const xml = feedTemplate({ 100 + self, 101 + alternate, 102 + title: `${profile.displayName} (attic.social bookmarks)`, 103 + author: profile.displayName, 104 + updated: new Date(bookmarks[0]?.createdAt ?? 0).toISOString(), 105 + entries: entries.join("\n"), 106 + }); 107 + 108 + return new Response(xml, { 109 + headers: { 110 + "Content-Type": "application/atom+xml", 111 + }, 112 + }); 113 + };
+8
static/images/rss.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" width="54" height="54" viewBox="0 0 54 54"> 2 + <path d="M11 44H9v-6h2v-2h6v2h2v6h-2v2h-6z" style="fill:#bd590f"/> 3 + <path d="M19 30h-4v-2H9v-6h8v2h4v2h2v2h2v2h2v2h2v2h2v4h2v8h-6v-6h-2v-4h-2v-2h-2v-2h-2z" style="fill:#bd590f"/> 4 + <path d="M17 16H9v-6h10v2h6v2h4v2h2v2h2v2h2v2h2v2h2v2h2v4h2v6h2v10h-6v-8h-2v-6h-2v-4h-2v-2h-2v-2h-2v-2h-2v-2h-4v-2h-6z" style="fill:#bd590f"/> 5 + <path d="M13 40h-2v-6h2v-2h6v2h2v6h-2v2h-6z" style="fill:#fff"/> 6 + <path d="M21 26h-4v-2h-6v-6h8v2h4v2h2v2h2v2h2v2h2v2h2v4h2v8h-6v-6h-2v-4h-2v-2h-2v-2h-2z" style="fill:#fff"/> 7 + <path d="M19 12h-8V6h10v2h6v2h4v2h2v2h2v2h2v2h2v2h2v2h2v4h2v6h2v10h-6v-8h-2v-6h-2v-4h-2v-2h-2v-2h-2v-2h-2v-2h-4v-2h-6z" style="fill:#fff"/> 8 + </svg>