char-slop/stars.hackery.site

git clone https://git.t4t.associates/char-slop/stars.hackery.site

Charlotte Somsyntactic pass: no casts, assertions, or void92004f9

main
10.5 KiB281 linesraw
1import type { Actor } from "./hydrant.ts";
2import type { StarEntry } from "./hydrant.ts";
3
4const css = `
5  :root { font-family: Arial, Helvetica, sans-serif; color: #111; background: #fff; }
6  * { box-sizing: border-box; }
7  body { max-width: 998px; margin: 0 auto; padding: 0 16px 64px; font-size: 16px; line-height: 1.5; }
8  a { color: #2c73af; text-underline-offset: 3px; }
9  a:hover { color: #195789; }
10  header { display: flex; align-items: center; justify-content: space-between; gap: 20px; min-height: 56px; }
11  header nav { display: flex; align-items: center; justify-content: flex-end; gap: 20px; min-width: 0; }
12  header nav a { overflow-wrap: anywhere; }
13  .nav-signin { padding: 5px 12px; border: 1px solid #aaa; border-radius: 4px; text-decoration: none; white-space: nowrap; }
14  main { padding-top: 28px; }
15  h1 { margin: 0 0 16px; font-size: 32px; line-height: 1.25; overflow-wrap: anywhere; }
16  .intro { margin: 0 0 18px; font-size: 14px; }
17  hr { border: 0; border-top: 1px solid #111; margin: 0 0 48px; }
18  .heading-actor { display: inline-flex; align-items: center; gap: 12px; }
19  .controls { display: flex; flex-wrap: wrap; align-items: end; justify-content: space-between; gap: 16px 32px; margin: -16px 0 32px; }
20  .controls form { margin: 0; }
21  .controls .browse { flex: 1 1 300px; max-width: 480px; }
22  .browse label { margin: 0 0 8px; }
23  .field-row { display: flex; align-items: stretch; gap: 8px; }
24  .field-row input { flex: 1; min-width: 0; width: auto; }
25  .field-row button { flex: none; }
26  #share { max-width: 560px; }
27  #share label[for] { margin-bottom: 0; }
28  .star { margin: 0 0 28px; padding: 16px; border: 1px solid #ddd; border-radius: 8px; box-shadow: 0 3px 15px #0003; }
29  .star-content { border-left: 5px solid #34383a; padding-left: 10px; }
30  .star-link { font-size: 20px; font-weight: 700; overflow-wrap: anywhere; }
31  .domain { display: block; overflow-wrap: anywhere; }
32  .comment { margin: 8px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }
33  .star-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 20px; margin-top: 11px; }
34  .author { display: inline-flex; align-items: center; gap: 6px; }
35  .avatar { width: 24px; height: 24px; border-radius: 50%; object-fit: cover; }
36  .avatar.large { width: 40px; height: 40px; }
37  form { margin: 24px 0; }
38  label { display: block; margin: 12px 0; }
39  input, textarea { display: block; width: 100%; padding: 9px 10px; font: inherit; border: 1px solid #aaa; border-radius: 4px; }
40  textarea { min-height: 180px; resize: vertical; }
41  button { padding: 7px 14px; font: inherit; color: #111; background: #f5f5f5; border: 1px solid #aaa; border-radius: 4px; cursor: pointer; }
42  button:hover { background: #e9e9e9; }
43  button.inline { font-size: 14px; }
44  .star form { margin: 12px 0 0; }
45  .panel { max-width: 480px; }
46  .muted { color: #555; }
47  @media (max-width: 600px) { body { padding: 0 16px 32px; } header { gap: 8px; min-height: 64px; } header nav { gap: 10px; } hr { margin-bottom: 32px; } .star { margin-bottom: 24px; } }
48`;
49
50// Only replaces a title the user hasn't written themselves.
51const shareScript = `
52const { url, title, csrf } = document.forms.share.elements;
53let fetched = "";
54const untouched = () => !title.value || title.value === fetched;
55const lookup = async () => {
56  const link = url.value;
57  if (!link || !url.checkValidity() || !untouched()) return;
58  title.placeholder = "Fetching title…";
59  try {
60    const res = await fetch("/xrpc/site.hackery.stars.getTitle", {
61      method: "POST",
62      headers: { "content-type": "application/json", "x-csrf-token": csrf.value },
63      body: JSON.stringify({ url: link }),
64    });
65    if (!res.ok || url.value !== link || !untouched()) return;
66    fetched = title.value = (await res.json()).title;
67  } finally {
68    title.placeholder = "";
69  }
70};
71url.addEventListener("change", lookup);
72lookup();
73`;
74const shareScriptHash = "sha256-" + new Uint8Array(
75  await crypto.subtle.digest("SHA-256", new TextEncoder().encode(shareScript)),
76).toBase64();
77
78const ENTITIES: Record<string, string> = {
79  "&": "&amp;",
80  "<": "&lt;",
81  ">": "&gt;",
82  '"': "&quot;",
83  "'": "&#39;",
84};
85export const escape = (s: string) => s.replace(/[&<>"']/g, (c) => ENTITIES[c]);
86
87export const profilePath = (identifier: string) =>
88  `/${encodeURIComponent(identifier).replaceAll("%3A", ":")}`;
89export const profileUrl = (actor: Actor) =>
90  profilePath(actor.handleVerified === false ? actor.did : actor.handle);
91
92const avatar = (actor: Actor, large = false) =>
93  actor.avatar
94    ? `<img class="avatar${large ? " large" : ""}" src="${
95      escape(actor.avatar)
96    }" alt="">`
97    : "★";
98const unverified = (actor: Actor) =>
99  actor.handleVerified === false
100    ? ' <small class="muted">(unverified)</small>'
101    : "";
102const actorLink = (actor: Actor) =>
103  `<a href="${escape(profileUrl(actor))}">${escape(actor.handle)}</a>`;
104
105export const notice = (text: string | undefined) =>
106  text ? `<p class="muted">${escape(text)}</p>` : "";
107export const list = (items: string[], empty: string, more?: string) =>
108  (items.join("") || `<p>${empty}</p>`) +
109  (more ? `<p><a href="${escape(more)}">Older stars →</a></p>` : "");
110
111export function page(
112  title: string,
113  body: string,
114  account?: { handle: string; url: string },
115): Response {
116  const nav = account
117    ? `<a href="${escape(account.url)}">${escape(account.handle)}</a>`
118    : '<a class="nav-signin" href="/login">Sign in</a>';
119  return new Response(
120    `<!doctype html>
121<html lang="en">
122<head>
123  <meta charset="utf-8">
124  <meta name="viewport" content="width=device-width,initial-scale=1">
125  <title>${escape(title)} - Starboard</title>
126  <style>${css}</style>
127</head>
128<body>
129  <header>
130    <a href="/">Starboard</a>
131    <nav><a href="/share">Add Star</a>${nav}</nav>
132  </header>
133  <main>${body}</main>
134</body>
135</html>`,
136    {
137      headers: {
138        "content-type": "text/html; charset=utf-8",
139        "cache-control": "no-store",
140        "x-content-type-options": "nosniff",
141        "content-security-policy":
142          `default-src 'none'; img-src https:; style-src 'unsafe-inline'; script-src '${shareScriptHash}'; connect-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`,
143      },
144    },
145  );
146}
147
148export const browseForm = `
149  <form class="browse" method="get" action="/go">
150    <label for="browse-handle">Find stars by handle or DID</label>
151    <div class="field-row">
152      <input id="browse-handle" name="handle" required placeholder="char.lt">
153      <button>Go</button>
154    </div>
155  </form>`;
156
157export const signInForm = `
158  <h1>Sign in</h1>
159  <p class="intro">Sign in with your ATProto account.</p>
160  <hr>
161  <form class="panel" method="post" action="/login">
162    <label>ATProto handle or DID<input name="handle" autocomplete="username" required placeholder="you.example.com"></label>
163    <button>Sign in</button>
164  </form>`;
165
166// The hidden Share button comes first so that it's the default button for Enter.
167export function shareForm(
168  csrf: string,
169  fields: { url?: string; title?: string; comment?: string; notice?: string },
170): string {
171  const url = escape(fields.url ?? "");
172  const title = escape(fields.title ?? "");
173  const comment = escape(fields.comment ?? "");
174  return `
175  <h1>Add Star</h1>
176  <p class="intro">Share a link and a comment.</p>
177  <hr>
178  ${notice(fields.notice)}
179  <form id="share" class="panel" method="post" action="/share">
180    <input type="hidden" name="csrf" value="${csrf}">
181    <button hidden></button>
182    <label>Link<input name="url" type="url" maxlength="2048" required value="${url}"></label>
183    <label for="share-title">Title (editable)</label>
184    <div class="field-row">
185      <input id="share-title" name="title" maxlength="2048" value="${title}">
186      <button formaction="/share/title">Fetch title</button>
187    </div>
188    <label>Comment (optional, up to 32,768 graphemes)<textarea name="comment">${comment}</textarea></label>
189    <button>Share</button>
190  </form>
191  <script>${shareScript}</script>`;
192}
193
194export function followForm(
195  csrf: string,
196  actor: Actor,
197  followed: boolean,
198): string {
199  return `
200  <form method="post" action="/${followed ? "unfollow" : "follow"}">
201    <input type="hidden" name="csrf" value="${csrf}">
202    <input type="hidden" name="subject" value="${escape(actor.did)}">
203    <button>${followed ? "Unfollow" : "Follow"}</button>
204  </form>`;
205}
206
207export const welcome = `
208  <h1>Starboard</h1>
209  <p class="intro">Links worth sharing, from people you follow.</p>
210  <hr>
211  <div class="panel">${browseForm}</div>`;
212
213export function feed(csrf: string, body: string): string {
214  return `
215  <h1>Starboard</h1>
216  <p class="intro">Links shared by people you follow.</p>
217  <hr>
218  <div class="controls">
219    ${browseForm}
220    <form method="post" action="/logout">
221      <input type="hidden" name="csrf" value="${csrf}">
222      <button class="inline">Sign out</button>
223    </form>
224  </div>${body}`;
225}
226
227export function profile(actor: Actor, follow: string, body: string): string {
228  const handle = escape(actor.handle);
229  const label = unverified(actor);
230  return `
231  <h1 class="heading-actor">${avatar(actor, true)} ${handle}${label}</h1>
232  <p class="intro">Links shared by ${handle}.</p>
233  <hr>
234  ${follow && `<div class="controls">${follow}</div>`}${body}`;
235}
236
237export function star(entry: StarEntry, actor: Actor, csrf?: string): string {
238  const { link } = entry;
239  const when = escape(entry.star.createdAt);
240  const date = new Date(entry.star.createdAt);
241  const label = Number.isNaN(date.valueOf())
242    ? when
243    : new Intl.DateTimeFormat("en-GB", {
244      day: "numeric",
245      month: "long",
246      year: "numeric",
247      hour: "2-digit",
248      minute: "2-digit",
249      hour12: false,
250      timeZone: "UTC",
251    }).format(date);
252  const domain = entry.star.title
253    ? `<small class="domain muted">${escape(new URL(link).hostname)}</small>`
254    : "";
255  const comment = entry.star.comment
256    ? `<p class="comment">${escape(entry.star.comment)}</p>`
257    : "";
258  const remove = csrf
259    ? `
260    <form method="post" action="/delete">
261      <input type="hidden" name="csrf" value="${csrf}">
262      <input type="hidden" name="uri" value="${escape(entry.uri)}">
263      <button class="inline">Delete</button>
264    </form>`
265    : "";
266  const author = `${avatar(actor)} ${actorLink(actor)}${unverified(actor)}`;
267  const href = escape(link);
268  const title = escape(entry.star.title || link);
269  return `
270  <article class="star">
271    <div class="star-content">
272      <a class="star-link" href="${href}" rel="noopener noreferrer nofollow">${title}</a>
273      ${domain}
274      ${comment}
275    </div>
276    <div class="star-meta">
277      <time datetime="${when}">Starred: ${label}</time>
278      <span class="author">${author}</span>
279    </div>${remove}
280  </article>`;
281}