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

fix ssr locale

dbushell.com 38cc460f 3e138124

verified
+411 -13
+1
src/app.d.ts
··· 7 7 interface Locals { 8 8 oAuthClient?: OAuthClient; 9 9 user?: PrivateUserData; 10 + locale?: string; 10 11 } 11 12 // interface PageData {} 12 13 // interface PageState {}
+12
src/css/components/bookmark.css
··· 3 3 gap: 20px; 4 4 } 5 5 6 + .Bookmarks-header { 7 + align-items: center; 8 + display: flex; 9 + flex-wrap: wrap; 10 + justify-content: space-between; 11 + 12 + & button { 13 + margin-block: -9px; 14 + } 15 + } 16 + 6 17 .Bookmark { 7 18 border: 5px solid rgb(var(--color-black)); 8 19 box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.1); ··· 39 50 font-size: var(--font-size-2); 40 51 font-weight: 700; 41 52 grid-column: 1; 53 + white-space: nowrap; 42 54 } 43 55 44 56 & > code {
+11 -1
src/hooks.server.ts
··· 1 1 import { dev } from "$app/environment"; 2 + import { acceptsLanguages } from "$lib/negotiation"; 2 3 import { restoreSession } from "$lib/server/session"; 3 4 import { isAuthEvent } from "$lib/types"; 4 5 import { error, type Handle } from "@sveltejs/kit"; ··· 19 20 if (isAuthEvent(event) === false) { 20 21 error(500); 21 22 } 23 + 24 + const languages = acceptsLanguages(event.request); 25 + event.locals.locale = languages[0] === "*" ? undefined : languages[0]; 26 + 22 27 // [TODO] necessary? 23 28 // if ( 24 29 // event.url.searchParams.has("session") || ··· 28 33 // ) { 29 34 await restoreSession(event); 30 35 // } 31 - return resolve(event); 36 + 37 + return resolve(event, { 38 + filterSerializedResponseHeaders: (name: string, _value: string) => { 39 + return ["content-type"].includes(name.toLowerCase()); 40 + }, 41 + }); 32 42 }; 33 43 34 44 export const handle: Handle = sequence(devHandle, defaultHandle);
+364
src/lib/negotiation.ts
··· 1 + // Copyright 2018-2026 the Deno authors. MIT license. 2 + /*! 3 + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ 4 + * which is licensed as follows: 5 + * 6 + * (The MIT License) 7 + * 8 + * Copyright (c) 2012-2014 Federico Romero 9 + * Copyright (c) 2012-2014 Isaac Z. Schlueter 10 + * Copyright (c) 2014-2015 Douglas Christopher Wilson 11 + * 12 + * Permission is hereby granted, free of charge, to any person obtaining 13 + * a copy of this software and associated documentation files (the 14 + * 'Software'), to deal in the Software without restriction, including 15 + * without limitation the rights to use, copy, modify, merge, publish, 16 + * distribute, sublicense, and/or sell copies of the Software, and to 17 + * permit persons to whom the Software is furnished to do so, subject to 18 + * the following conditions: 19 + * 20 + * The above copyright notice and this permission notice shall be 21 + * included in all copies or substantial portions of the Software. 22 + * 23 + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 24 + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 25 + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 26 + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 27 + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 28 + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 29 + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 30 + */ 31 + 32 + export interface Specificity { 33 + i: number; 34 + o: number | undefined; 35 + q: number; 36 + s: number | undefined; 37 + } 38 + 39 + export function compareSpecs(a: Specificity, b: Specificity): number { 40 + return ( 41 + b.q - a.q || 42 + (b.s ?? 0) - (a.s ?? 0) || 43 + (a.o ?? 0) - (b.o ?? 0) || 44 + a.i - b.i || 45 + 0 46 + ); 47 + } 48 + 49 + export function isQuality(spec: Specificity): boolean { 50 + return spec.q > 0; 51 + } 52 + 53 + interface LanguageSpecificity extends Specificity { 54 + prefix: string; 55 + suffix: string | undefined; 56 + full: string; 57 + } 58 + 59 + const SIMPLE_LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; 60 + 61 + function parseLanguage( 62 + str: string, 63 + i: number, 64 + ): LanguageSpecificity | undefined { 65 + const match = SIMPLE_LANGUAGE_REGEXP.exec(str); 66 + if (!match) { 67 + return undefined; 68 + } 69 + 70 + const [, prefix, suffix] = match; 71 + if (!prefix) { 72 + return undefined; 73 + } 74 + 75 + const full = suffix !== undefined ? `${prefix}-${suffix}` : prefix; 76 + 77 + let q = 1; 78 + if (match[3]) { 79 + const params = match[3].split(";"); 80 + for (const param of params) { 81 + const [key, value] = param.trim().split("="); 82 + if (key === "q" && value) { 83 + q = parseFloat(value); 84 + break; 85 + } 86 + } 87 + } 88 + 89 + return { prefix, suffix, full, i, o: undefined, q, s: undefined }; 90 + } 91 + 92 + function parseAcceptLanguage(accept: string): LanguageSpecificity[] { 93 + const accepts = accept.split(","); 94 + const result: LanguageSpecificity[] = []; 95 + 96 + for (const [i, accept] of accepts.entries()) { 97 + const language = parseLanguage(accept.trim(), i); 98 + if (language) { 99 + result.push(language); 100 + } 101 + } 102 + return result; 103 + } 104 + 105 + function specify( 106 + language: string, 107 + spec: LanguageSpecificity, 108 + i: number, 109 + ): Specificity | undefined { 110 + const p = parseLanguage(language, i); 111 + if (!p) { 112 + return undefined; 113 + } 114 + let s = 0; 115 + if (spec.full.toLowerCase() === p.full.toLowerCase()) { 116 + s |= 4; 117 + } else if (spec.prefix.toLowerCase() === p.prefix.toLowerCase()) { 118 + s |= 2; 119 + } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { 120 + s |= 1; 121 + } else if (spec.full !== "*") { 122 + return; 123 + } 124 + 125 + return { i, o: spec.i, q: spec.q, s }; 126 + } 127 + 128 + function getLanguagePriority( 129 + language: string, 130 + accepted: LanguageSpecificity[], 131 + index: number, 132 + ): Specificity { 133 + let priority: Specificity = { i: -1, o: -1, q: 0, s: 0 }; 134 + for (const accepts of accepted) { 135 + const spec = specify(language, accepts, index); 136 + if ( 137 + spec && 138 + ((priority.s ?? 0) - (spec.s ?? 0) || priority.q - spec.q || 139 + (priority.o ?? 0) - (spec.o ?? 0)) < 0 140 + ) { 141 + priority = spec; 142 + } 143 + } 144 + return priority; 145 + } 146 + 147 + export function preferredLanguages( 148 + accept = "*", 149 + provided?: string[], 150 + ): string[] { 151 + const accepts = parseAcceptLanguage(accept); 152 + 153 + if (!provided) { 154 + return accepts 155 + .filter(isQuality) 156 + .sort(compareSpecs) 157 + .map((spec) => spec.full); 158 + } 159 + 160 + const priorities = provided 161 + .map((type, index) => getLanguagePriority(type, accepts, index)); 162 + 163 + return priorities 164 + .filter(isQuality) 165 + .sort(compareSpecs) 166 + .map((priority) => provided[priorities.indexOf(priority)]!); 167 + } 168 + 169 + /** 170 + * Returns an array of media types accepted by the request, in order of 171 + * preference. If there are no media types supplied in the request, then any 172 + * media type selector will be returned. 173 + * 174 + * @example Usage 175 + * ```ts 176 + * import { accepts } from "@std/http/negotiation"; 177 + * import { assertEquals } from "@std/assert"; 178 + * 179 + * const request = new Request("https://example.com/", { 180 + * headers: { 181 + * accept: 182 + * "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, *\/*;q=0.8", 183 + * }, 184 + * }); 185 + * 186 + * assertEquals(accepts(request), [ 187 + * "text/html", 188 + * "application/xhtml+xml", 189 + * "image/webp", 190 + * "application/xml", 191 + * "*\/*", 192 + * ]); 193 + * ``` 194 + * 195 + * @param request The request to get the acceptable media types for. 196 + * @returns An array of acceptable media types. 197 + */ 198 + export function accepts(request: Pick<Request, "headers">): string[]; 199 + /** 200 + * For a given set of media types, return the best match accepted in the 201 + * request. If no media type matches, then the function returns `undefined`. 202 + * 203 + * @example Usage 204 + * ```ts 205 + * import { accepts } from "@std/http/negotiation"; 206 + * import { assertEquals } from "@std/assert"; 207 + * 208 + * const request = new Request("https://example.com/", { 209 + * headers: { 210 + * accept: 211 + * "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, *\/*;q=0.8", 212 + * }, 213 + * }); 214 + * 215 + * assertEquals(accepts(request, "text/html", "image/webp"), "text/html"); 216 + * ``` 217 + * 218 + * @typeParam T The type of supported content-types (if provided). 219 + * @param request The request to get the acceptable media types for. 220 + * @param types An array of media types to find the best matching one from. 221 + * @returns The best matching media type, if any match. 222 + */ 223 + export function accepts<T extends string = string>( 224 + request: Pick<Request, "headers">, 225 + ...types: T[] 226 + ): T | undefined; 227 + export function accepts( 228 + request: Pick<Request, "headers">, 229 + ...types: string[] 230 + ): string | string[] | undefined { 231 + const accept = request.headers.get("accept"); 232 + return types.length 233 + ? accept ? preferredMediaTypes(accept, types)[0] : types[0] 234 + : accept 235 + ? preferredMediaTypes(accept) 236 + : ["*/*"]; 237 + } 238 + 239 + /** 240 + * Returns an array of content encodings accepted by the request, in order of 241 + * preference. If there are no encoding supplied in the request, then `["*"]` 242 + * is returned, implying any encoding is accepted. 243 + * 244 + * @example Usage 245 + * ```ts 246 + * import { acceptsEncodings } from "@std/http/negotiation"; 247 + * import { assertEquals } from "@std/assert"; 248 + * 249 + * const request = new Request("https://example.com/", { 250 + * headers: { "accept-encoding": "deflate, gzip;q=1.0, *;q=0.5" }, 251 + * }); 252 + * 253 + * assertEquals(acceptsEncodings(request), ["deflate", "gzip", "*"]); 254 + * ``` 255 + * 256 + * @param request The request to get the acceptable content encodings for. 257 + * @returns An array of content encodings this request accepts. 258 + */ 259 + export function acceptsEncodings(request: Pick<Request, "headers">): string[]; 260 + /** 261 + * For a given set of content encodings, return the best match accepted in the 262 + * request. If no content encodings match, then the function returns 263 + * `undefined`. 264 + * 265 + * **NOTE:** You should always supply `identity` as one of the encodings 266 + * to ensure that there is a match when the `Accept-Encoding` header is part 267 + * of the request. 268 + * 269 + * @example Usage 270 + * ```ts 271 + * import { acceptsEncodings } from "@std/http/negotiation"; 272 + * import { assertEquals } from "@std/assert"; 273 + * 274 + * const request = new Request("https://example.com/", { 275 + * headers: { "accept-encoding": "deflate, gzip;q=1.0, *;q=0.5" }, 276 + * }); 277 + * 278 + * assertEquals(acceptsEncodings(request, "gzip", "identity"), "gzip"); 279 + * ``` 280 + * 281 + * @typeParam T The type of supported encodings (if provided). 282 + * @param request The request to get the acceptable content encodings for. 283 + * @param encodings An array of encodings to find the best matching one from. 284 + * @returns The best matching encoding, if any match. 285 + */ 286 + export function acceptsEncodings<T extends string = string>( 287 + request: Pick<Request, "headers">, 288 + ...encodings: T[] 289 + ): T | undefined; 290 + export function acceptsEncodings( 291 + request: Pick<Request, "headers">, 292 + ...encodings: string[] 293 + ): string | string[] | undefined { 294 + const acceptEncoding = request.headers.get("accept-encoding"); 295 + return encodings.length 296 + ? acceptEncoding 297 + ? preferredEncodings(acceptEncoding, encodings)[0] 298 + : encodings[0] 299 + : acceptEncoding 300 + ? preferredEncodings(acceptEncoding) 301 + : ["*"]; 302 + } 303 + 304 + /** 305 + * Returns an array of languages accepted by the request, in order of 306 + * preference. If there are no languages supplied in the request, then `["*"]` 307 + * is returned, imply any language is accepted. 308 + * 309 + * @example Usage 310 + * ```ts 311 + * import { acceptsLanguages } from "@std/http/negotiation"; 312 + * import { assertEquals } from "@std/assert"; 313 + * 314 + * const request = new Request("https://example.com/", { 315 + * headers: { 316 + * "accept-language": "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", 317 + * }, 318 + * }); 319 + * 320 + * assertEquals(acceptsLanguages(request), ["fr-CH", "fr", "en", "de", "*"]); 321 + * ``` 322 + * 323 + * @param request The request to get the acceptable languages for. 324 + * @returns An array of languages this request accepts. 325 + */ 326 + export function acceptsLanguages(request: Pick<Request, "headers">): string[]; 327 + /** 328 + * For a given set of languages, return the best match accepted in the request. 329 + * If no languages match, then the function returns `undefined`. 330 + * 331 + * @example Usage 332 + * ```ts 333 + * import { acceptsLanguages } from "@std/http/negotiation"; 334 + * import { assertEquals } from "@std/assert"; 335 + * 336 + * const request = new Request("https://example.com/", { 337 + * headers: { 338 + * "accept-language": "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", 339 + * }, 340 + * }); 341 + * 342 + * assertEquals(acceptsLanguages(request, "en-gb", "en-us", "en"), "en"); 343 + * ``` 344 + * 345 + * @typeParam T The type of supported languages (if provided). 346 + * @param request The request to get the acceptable language for. 347 + * @param langs An array of languages to find the best matching one from. 348 + * @returns The best matching language, if any match. 349 + */ 350 + export function acceptsLanguages<T extends string = string>( 351 + request: Pick<Request, "headers">, 352 + ...langs: T[] 353 + ): T | undefined; 354 + export function acceptsLanguages( 355 + request: Pick<Request, "headers">, 356 + ...langs: string[] 357 + ): string | string[] | undefined { 358 + const acceptLanguage = request.headers.get("accept-language"); 359 + return langs.length 360 + ? acceptLanguage ? preferredLanguages(acceptLanguage, langs)[0] : langs[0] 361 + : acceptLanguage 362 + ? preferredLanguages(acceptLanguage) 363 + : ["*"]; 364 + }
+7
src/routes/bookmarks/[did=did]/+page.server.ts
··· 2 2 import { parseBookmark } from "$lib/valibot"; 3 3 import * as TID from "@atcute/tid"; 4 4 import { type Actions, fail } from "@sveltejs/kit"; 5 + import type { PageServerLoad } from "./$types"; 6 + 7 + export const load: PageServerLoad = async ({ locals }) => { 8 + return { 9 + locale: locals.locale, 10 + }; 11 + }; 5 12 6 13 export const actions = { 7 14 deleteBookmark: async (event) => {
+8 -6
src/routes/bookmarks/[did=did]/+page.svelte
··· 10 10 let editDialog: HTMLDialogElement | null = $state(null); 11 11 let createDialog: HTMLDialogElement | null = $state(null); 12 12 13 + const dateFormat = $derived( 14 + new Intl.DateTimeFormat(data.locale, { 15 + dateStyle: "medium", 16 + timeStyle: "short", 17 + }), 18 + ); 19 + 13 20 $effect(() => { 14 21 if (form?.action === "editBookmark" && "error" in form) { 15 22 if (editDialog?.open === false) { ··· 31 38 if (form?.action === "deleteBookmark") { 32 39 if (form.error) alert(form.error); 33 40 } 34 - }); 35 - 36 - const dateFormat = new Intl.DateTimeFormat(undefined, { 37 - dateStyle: "medium", 38 - timeStyle: "short", 39 41 }); 40 42 </script> 41 43 ··· 140 142 141 143 {#if data.bookmarks.length} 142 144 <div class="Bookmarks"> 143 - <div class="flex flex-wrap ai-center jc-between"> 145 + <div class="Bookmarks-header"> 144 146 <h2>Bookmarks</h2> 145 147 {#if isSelf} 146 148 <button type="button" onclick={() => createDialog?.showModal()}>
+8 -6
src/routes/bookmarks/[did=did]/+page.ts
··· 8 8 import { error } from "@sveltejs/kit"; 9 9 import type { PageLoad } from "./$types"; 10 10 11 - export const load: PageLoad = async ({ params }) => { 11 + export const load: PageLoad = async ({ params, data, fetch }) => { 12 12 const pds = await resolvePDS(params.did); 13 + 13 14 if (pds === null) { 14 15 error(404); 15 16 } 16 17 const rpc = new Client({ 17 - handler: simpleFetchHandler({ service: pds }), 18 + handler: simpleFetchHandler({ fetch, service: pds }), 18 19 }); 19 20 const response = await rpc.get("com.atproto.repo.getRecord", { 20 21 params: { ··· 41 42 break; 42 43 } 43 44 cursor = response.data.cursor; 44 - for (const data of response.data.records) { 45 + for (const entity of response.data.records) { 45 46 try { 46 47 bookmarks.push({ 47 - cid: data.cid, 48 - uri: data.uri, 49 - ...parseBookmark(data.value), 48 + cid: entity.cid, 49 + uri: entity.uri, 50 + ...parseBookmark(entity.value), 50 51 }); 51 52 } catch { 52 53 // [TODO] delete invalid data? ··· 56 57 try { 57 58 const profile = parseActorProfile(response.data.value); 58 59 return { 60 + ...data, 59 61 profile, 60 62 bookmarks, 61 63 };