diff --git a/src/css/base/global.css b/src/css/base/global.css index 6f15dd1..d4a8825 100644 --- a/src/css/base/global.css +++ b/src/css/base/global.css @@ -92,11 +92,28 @@ attic-app { } dialog { - inline-size: min(min(100%, 900px), calc(100vi - (2 * var(--app-margin)))); + inline-size: 100%; + max-block-size: 100vb; + margin-inline: 0; + overscroll-behavior: contain; + padding-block: 10px; + + & > form { + inline-size: calc(100vi - 20px); + margin-inline: auto; + } + + @media (width >= 600px) { + padding-block: 30px; + + & > form { + inline-size: min(min(100%, 900px), calc(100vi - (2 * var(--app-margin)))); + } + } & [command="close"] { inset-block-start: 20px; - inset-inline-end: 20px; + inset-inline-end: 0; position: absolute; z-index: 1; diff --git a/src/css/base/properties.css b/src/css/base/properties.css index 42a751c..85ff51a 100644 --- a/src/css/base/properties.css +++ b/src/css/base/properties.css @@ -23,6 +23,7 @@ --app-margin: max(20px, min(5vi, 100px)); --input-border: url('data:image/svg+xml;utf8,'); + --input-border-error: url('data:image/svg+xml;utf8,'); --button-border: url('data:image/svg+xml;utf8,'); --button-border-hover: url('data:image/svg+xml;utf8,'); --button-border-danger: url('data:image/svg+xml;utf8,'); diff --git a/src/css/components/bookmark.css b/src/css/components/bookmark.css index df0ee3f..cd2e8d5 100644 --- a/src/css/components/bookmark.css +++ b/src/css/components/bookmark.css @@ -36,16 +36,20 @@ box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.5); } - & > :is(h2, h3, .flex) { - grid-column: 1 / -1; - } - & > :is(h2, h3) { font-size: var(--font-size-3); + grid-column: 1 / -1; + + &:has(button) { + align-items: start; + grid-template-columns: 1fr auto; + display: grid; + gap: 10px; + } & a { align-items: start; - column-gap: 5px; + column-gap: 10px; display: flex; &::after { @@ -85,9 +89,30 @@ & form { display: contents; + z-index: 1; } - & :is(button, form) { + & button { + margin-block: -10px; + margin-inline-end: -10px; z-index: 1; } + + & ul { + display: flex; + flex-wrap: wrap; + gap: 5px; + grid-column: 1 / -1; + + & li { + background: rgb(var(--color-yellow)); + color: rgb(var(--color-black)); + font-size: var(--font-size-1); + font-weight: 700; + display: block; + line-height: 1; + min-inline-size: 0; + padding: 5px; + } + } } diff --git a/src/css/components/form.css b/src/css/components/form.css index e01659c..1d8ff1f 100644 --- a/src/css/components/form.css +++ b/src/css/components/form.css @@ -12,6 +12,7 @@ form { justify-items: start; padding: 30px; padding-block-start: 25px; + position: relative; & label { display: block; @@ -19,6 +20,10 @@ form { font-weight: 700; inline-size: fit-content; line-height: var(--line-height-2); + + & span { + font-weight: 400; + } } & > * { @@ -29,13 +34,8 @@ form { inline-size: 100%; } - &[action*="editBookmark"] { - & input { - inline-size: 100%; - } - } - - &[action*="createBookmark"] { + :where(&[action*="editBookmark"]), + :where(&[action*="createBookmark"]) { & input { inline-size: 100%; } @@ -43,11 +43,17 @@ form { & div:has(input[name="title"]) { display: grid; inline-size: 100%; - grid-template-columns: 1fr auto; gap: 10px; - & input { - grid-column: 1; + @media (width >= 600px) { + grid-template-columns: 1fr auto; + & input { + grid-column: 1; + } + + & button { + grid-column: 2; + } } } } diff --git a/src/css/components/input.css b/src/css/components/input.css index cd0cb48..c74c1f2 100644 --- a/src/css/components/input.css +++ b/src/css/components/input.css @@ -1,4 +1,5 @@ -input { +input, +.Tags { border: 15px solid transparent; border-image: var(--input-border) 15 fill stretch; block-size: calc(var(--font-size-button) + 30px); @@ -9,7 +10,9 @@ input { inline-size: min(100%, 400px); line-height: var(--line-height-1); padding: 0; +} +input { &::placeholder { color: rgb(var(--color-black) / 0.5); opacity: 1; @@ -19,4 +22,64 @@ input { opacity: 0.5; pointer-events: none; } + + &:user-invalid { + border-image-source: var(--input-border-error); + } +} + +.Tags { + block-size: auto; + column-gap: 5px; + display: flex; + flex-wrap: wrap; + inline-size: 100%; + min-inline-size: 0; + row-gap: 15px; + + &:has(:focus-visible) { + outline: 4px solid magenta; + outline-offset: 2px; + } + + & :is(input) { + border: 2px solid black; + block-size: calc(var(--font-size-button) + 10px); + field-sizing: content; + inline-size: fit-content; + line-height: 1; + margin-block: -5px; + min-inline-size: 0; + overflow: hidden; + padding: 5px; + text-overflow: ellipsis; + white-space: nowrap; + + &:focus-visible { + outline: none; + } + + &:not([readonly]) { + border-color: transparent; + min-inline-size: 5ch; + flex-grow: 1; + } + + &[readonly] { + background: rgb(var(--color-yellow)); + + &::selection { + background: transparent; + } + + &:focus-visible { + background: rgb(var(--color-light-yellow)); + } + + &:hover { + background: rgb(var(--color-light-yellow)); + cursor: pointer; + } + } + } } diff --git a/src/lib/_negotiation/common.ts b/src/lib/_negotiation/common.ts new file mode 100644 index 0000000..ff7368e --- /dev/null +++ b/src/lib/_negotiation/common.ts @@ -0,0 +1,51 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +/*! + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ + * which is licensed as follows: + * + * (The MIT License) + * + * Copyright (c) 2012-2014 Federico Romero + * Copyright (c) 2012-2014 Isaac Z. Schlueter + * Copyright (c) 2014-2015 Douglas Christopher Wilson + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export interface Specificity { + i: number; + o: number | undefined; + q: number; + s: number | undefined; +} + +export function compareSpecs(a: Specificity, b: Specificity): number { + return ( + b.q - a.q || + (b.s ?? 0) - (a.s ?? 0) || + (a.o ?? 0) - (b.o ?? 0) || + a.i - b.i || + 0 + ); +} + +export function isQuality(spec: Specificity): boolean { + return spec.q > 0; +} diff --git a/src/lib/_negotiation/encoding.ts b/src/lib/_negotiation/encoding.ts new file mode 100644 index 0000000..ee7aad7 --- /dev/null +++ b/src/lib/_negotiation/encoding.ts @@ -0,0 +1,164 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +/*! + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ + * which is licensed as follows: + * + * (The MIT License) + * + * Copyright (c) 2012-2014 Federico Romero + * Copyright (c) 2012-2014 Isaac Z. Schlueter + * Copyright (c) 2014-2015 Douglas Christopher Wilson + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { compareSpecs, isQuality, type Specificity } from "./common.ts"; + +interface EncodingSpecificity extends Specificity { + encoding?: string; +} + +const simpleEncodingRegExp = /^\s*([^\s;]+)\s*(?:;(.*))?$/; + +function parseEncoding( + str: string, + i: number, +): EncodingSpecificity | undefined { + const match = simpleEncodingRegExp.exec(str); + if (!match) { + return undefined; + } + + const encoding = match[1]!; + let q = 1; + if (match[2]) { + const params = match[2].split(";"); + for (const param of params) { + const p = param.trim().split("="); + if (p[0] === "q" && p[1]) { + q = parseFloat(p[1]); + break; + } + } + } + + return { encoding, o: undefined, q, i, s: undefined }; +} + +function specify( + encoding: string, + spec: EncodingSpecificity, + i = -1, +): Specificity | undefined { + if (!spec.encoding) { + return; + } + let s = 0; + if (spec.encoding.toLowerCase() === encoding.toLowerCase()) { + s = 1; + } else if (spec.encoding !== "*") { + return; + } + + return { + i, + o: spec.i, + q: spec.q, + s, + }; +} + +function parseAcceptEncoding(accept: string): EncodingSpecificity[] { + const accepts = accept.split(","); + const parsedAccepts: EncodingSpecificity[] = []; + let hasIdentity = false; + let minQuality = 1; + + for (const [i, accept] of accepts.entries()) { + const encoding = parseEncoding(accept.trim(), i); + + if (encoding) { + parsedAccepts.push(encoding); + hasIdentity = hasIdentity || !!specify("identity", encoding); + minQuality = Math.min(minQuality, encoding.q || 1); + } + } + + if (!hasIdentity) { + parsedAccepts.push({ + encoding: "identity", + o: undefined, + q: minQuality, + i: accepts.length - 1, + s: undefined, + }); + } + + return parsedAccepts; +} + +function getEncodingPriority( + encoding: string, + accepted: Specificity[], + index: number, +): Specificity { + let priority: Specificity = { o: -1, q: 0, s: 0, i: 0 }; + + for (const s of accepted) { + const spec = specify(encoding, s, index); + + if ( + spec && + (priority.s! - spec.s! || priority.q - spec.q || + priority.o! - spec.o!) < + 0 + ) { + priority = spec; + } + } + + return priority; +} + +/** Given an `Accept-Encoding` string, parse out the encoding returning a + * negotiated encoding based on the `provided` encodings otherwise just a + * prioritized array of encodings. */ +export function preferredEncodings( + accept: string, + provided?: string[], +): string[] { + const accepts = parseAcceptEncoding(accept); + + if (!provided) { + return accepts + .filter(isQuality) + .sort(compareSpecs) + .map((spec) => spec.encoding!); + } + + const priorities = provided.map((type, index) => + getEncodingPriority(type, accepts, index) + ); + + return priorities + .filter(isQuality) + .sort(compareSpecs) + .map((priority) => provided[priorities.indexOf(priority)]!); +} diff --git a/src/lib/_negotiation/language.ts b/src/lib/_negotiation/language.ts new file mode 100644 index 0000000..4aec153 --- /dev/null +++ b/src/lib/_negotiation/language.ts @@ -0,0 +1,148 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +/*! + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ + * which is licensed as follows: + * + * (The MIT License) + * + * Copyright (c) 2012-2014 Federico Romero + * Copyright (c) 2012-2014 Isaac Z. Schlueter + * Copyright (c) 2014-2015 Douglas Christopher Wilson + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { compareSpecs, isQuality, type Specificity } from "./common.ts"; + +interface LanguageSpecificity extends Specificity { + prefix: string; + suffix: string | undefined; + full: string; +} + +const SIMPLE_LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; + +function parseLanguage( + str: string, + i: number, +): LanguageSpecificity | undefined { + const match = SIMPLE_LANGUAGE_REGEXP.exec(str); + if (!match) { + return undefined; + } + + const [, prefix, suffix] = match; + if (!prefix) { + return undefined; + } + + const full = suffix !== undefined ? `${prefix}-${suffix}` : prefix; + + let q = 1; + if (match[3]) { + const params = match[3].split(";"); + for (const param of params) { + const [key, value] = param.trim().split("="); + if (key === "q" && value) { + q = parseFloat(value); + break; + } + } + } + + return { prefix, suffix, full, i, o: undefined, q, s: undefined }; +} + +function parseAcceptLanguage(accept: string): LanguageSpecificity[] { + const accepts = accept.split(","); + const result: LanguageSpecificity[] = []; + + for (const [i, accept] of accepts.entries()) { + const language = parseLanguage(accept.trim(), i); + if (language) { + result.push(language); + } + } + return result; +} + +function specify( + language: string, + spec: LanguageSpecificity, + i: number, +): Specificity | undefined { + const p = parseLanguage(language, i); + if (!p) { + return undefined; + } + let s = 0; + if (spec.full.toLowerCase() === p.full.toLowerCase()) { + s |= 4; + } else if (spec.prefix.toLowerCase() === p.prefix.toLowerCase()) { + s |= 2; + } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { + s |= 1; + } else if (spec.full !== "*") { + return; + } + + return { i, o: spec.i, q: spec.q, s }; +} + +function getLanguagePriority( + language: string, + accepted: LanguageSpecificity[], + index: number, +): Specificity { + let priority: Specificity = { i: -1, o: -1, q: 0, s: 0 }; + for (const accepts of accepted) { + const spec = specify(language, accepts, index); + if ( + spec && + ((priority.s ?? 0) - (spec.s ?? 0) || priority.q - spec.q || + (priority.o ?? 0) - (spec.o ?? 0)) < 0 + ) { + priority = spec; + } + } + return priority; +} + +export function preferredLanguages( + accept = "*", + provided?: string[], +): string[] { + const accepts = parseAcceptLanguage(accept); + + if (!provided) { + return accepts + .filter(isQuality) + .sort(compareSpecs) + .map((spec) => spec.full); + } + + const priorities = provided + .map((type, index) => getLanguagePriority(type, accepts, index)); + + return priorities + .filter(isQuality) + .sort(compareSpecs) + .map((priority) => provided[priorities.indexOf(priority)]!); +} diff --git a/src/lib/_negotiation/media_type.ts b/src/lib/_negotiation/media_type.ts new file mode 100644 index 0000000..b376625 --- /dev/null +++ b/src/lib/_negotiation/media_type.ts @@ -0,0 +1,196 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +/*! + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ + * which is licensed as follows: + * + * (The MIT License) + * + * Copyright (c) 2012-2014 Federico Romero + * Copyright (c) 2012-2014 Isaac Z. Schlueter + * Copyright (c) 2014-2015 Douglas Christopher Wilson + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +import { compareSpecs, isQuality, type Specificity } from "./common.ts"; + +interface MediaTypeSpecificity extends Specificity { + type: string; + subtype: string; + params: { [param: string]: string | undefined }; +} + +const simpleMediaTypeRegExp = /^\s*([^\s\/;]+)\/([^;\s]+)\s*(?:;(.*))?$/; + +function splitKeyValuePair(str: string): [string, string | undefined] { + const [key, value] = str.split("="); + return [key!.toLowerCase(), value]; +} + +function parseMediaType( + str: string, + i: number, +): MediaTypeSpecificity | undefined { + const match = simpleMediaTypeRegExp.exec(str); + + if (!match) { + return; + } + + const [, type, subtype, parameters] = match; + if (!type || !subtype) { + return; + } + + const params: { [param: string]: string | undefined } = Object.create(null); + let q = 1; + if (parameters) { + const kvps = parameters.split(";").map((p) => p.trim()).map( + splitKeyValuePair, + ); + + for (const [key, val] of kvps) { + const value = val && val[0] === `"` && val[val.length - 1] === `"` + ? val.slice(1, val.length - 1) + : val; + + if (key === "q" && value) { + q = parseFloat(value); + break; + } + + params[key] = value; + } + } + + return { type, subtype, params, i, o: undefined, q, s: undefined }; +} + +function parseAccept(accept: string): MediaTypeSpecificity[] { + const accepts = accept.split(",").map((p) => p.trim()); + + const mediaTypes: MediaTypeSpecificity[] = []; + for (const [index, accept] of accepts.entries()) { + const mediaType = parseMediaType(accept.trim(), index); + + if (mediaType) { + mediaTypes.push(mediaType); + } + } + + return mediaTypes; +} + +function getFullType(spec: MediaTypeSpecificity) { + return `${spec.type}/${spec.subtype}`; +} + +function specify( + type: string, + spec: MediaTypeSpecificity, + index: number, +): Specificity | undefined { + const p = parseMediaType(type, index); + + if (!p) { + return; + } + + let s = 0; + + if (spec.type.toLowerCase() === p.type.toLowerCase()) { + s |= 4; + } else if (spec.type !== "*") { + return; + } + + if (spec.subtype.toLowerCase() === p.subtype.toLowerCase()) { + s |= 2; + } else if (spec.subtype !== "*") { + return; + } + + const keys = Object.keys(spec.params); + if (keys.length) { + if ( + keys.every((key) => + (spec.params[key] ?? "").toLowerCase() === + (p.params[key] ?? "").toLowerCase() + ) + ) { + s |= 1; + } else { + return; + } + } + + return { + i: index, + o: spec.o, + q: spec.q, + s, + }; +} + +function getMediaTypePriority( + type: string, + accepted: MediaTypeSpecificity[], + index: number, +) { + let priority: Specificity = { o: -1, q: 0, s: 0, i: index }; + + for (const accepts of accepted) { + const spec = specify(type, accepts, index); + + if ( + spec && + ((priority.s ?? 0) - (spec.s ?? 0) || + (priority.q ?? 0) - (spec.q ?? 0) || + (priority.o ?? 0) - (spec.o ?? 0)) < 0 + ) { + priority = spec; + } + } + + return priority; +} + +export function preferredMediaTypes( + accept?: string | null, + provided?: string[], +): string[] { + const accepts = parseAccept(accept === undefined ? "*/*" : accept ?? ""); + + if (!provided) { + return accepts + .filter(isQuality) + .sort(compareSpecs) + .map(getFullType); + } + + const priorities = provided.map((type, index) => { + return getMediaTypePriority(type, accepts, index); + }); + + return priorities + .filter(isQuality) + .sort(compareSpecs) + .map((priority) => provided[priorities.indexOf(priority)]!); +} diff --git a/src/lib/negotiation.ts b/src/lib/negotiation.ts index aab0b36..1885e59 100644 --- a/src/lib/negotiation.ts +++ b/src/lib/negotiation.ts @@ -1,170 +1,16 @@ // Copyright 2018-2026 the Deno authors. MIT license. -/*! - * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ - * which is licensed as follows: - * - * (The MIT License) - * - * Copyright (c) 2012-2014 Federico Romero - * Copyright (c) 2012-2014 Isaac Z. Schlueter - * Copyright (c) 2014-2015 Douglas Christopher Wilson - * - * Permission is hereby granted, free of charge, to any person obtaining - * a copy of this software and associated documentation files (the - * 'Software'), to deal in the Software without restriction, including - * without limitation the rights to use, copy, modify, merge, publish, - * distribute, sublicense, and/or sell copies of the Software, and to - * permit persons to whom the Software is furnished to do so, subject to - * the following conditions: - * - * The above copyright notice and this permission notice shall be - * included in all copies or substantial portions of the Software. +// This module is browser compatible. + +/** + * Contains the functions {@linkcode accepts}, {@linkcode acceptsEncodings}, and + * {@linkcode acceptsLanguages} to provide content negotiation capabilities. * - * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, - * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. - * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY - * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, - * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE - * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * @module */ -export interface Specificity { - i: number; - o: number | undefined; - q: number; - s: number | undefined; -} - -export function compareSpecs(a: Specificity, b: Specificity): number { - return ( - b.q - a.q || - (b.s ?? 0) - (a.s ?? 0) || - (a.o ?? 0) - (b.o ?? 0) || - a.i - b.i || - 0 - ); -} - -export function isQuality(spec: Specificity): boolean { - return spec.q > 0; -} - -interface LanguageSpecificity extends Specificity { - prefix: string; - suffix: string | undefined; - full: string; -} - -const SIMPLE_LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; - -function parseLanguage( - str: string, - i: number, -): LanguageSpecificity | undefined { - const match = SIMPLE_LANGUAGE_REGEXP.exec(str); - if (!match) { - return undefined; - } - - const [, prefix, suffix] = match; - if (!prefix) { - return undefined; - } - - const full = suffix !== undefined ? `${prefix}-${suffix}` : prefix; - - let q = 1; - if (match[3]) { - const params = match[3].split(";"); - for (const param of params) { - const [key, value] = param.trim().split("="); - if (key === "q" && value) { - q = parseFloat(value); - break; - } - } - } - - return { prefix, suffix, full, i, o: undefined, q, s: undefined }; -} - -function parseAcceptLanguage(accept: string): LanguageSpecificity[] { - const accepts = accept.split(","); - const result: LanguageSpecificity[] = []; - - for (const [i, accept] of accepts.entries()) { - const language = parseLanguage(accept.trim(), i); - if (language) { - result.push(language); - } - } - return result; -} - -function specify( - language: string, - spec: LanguageSpecificity, - i: number, -): Specificity | undefined { - const p = parseLanguage(language, i); - if (!p) { - return undefined; - } - let s = 0; - if (spec.full.toLowerCase() === p.full.toLowerCase()) { - s |= 4; - } else if (spec.prefix.toLowerCase() === p.prefix.toLowerCase()) { - s |= 2; - } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { - s |= 1; - } else if (spec.full !== "*") { - return; - } - - return { i, o: spec.i, q: spec.q, s }; -} - -function getLanguagePriority( - language: string, - accepted: LanguageSpecificity[], - index: number, -): Specificity { - let priority: Specificity = { i: -1, o: -1, q: 0, s: 0 }; - for (const accepts of accepted) { - const spec = specify(language, accepts, index); - if ( - spec && - ((priority.s ?? 0) - (spec.s ?? 0) || priority.q - spec.q || - (priority.o ?? 0) - (spec.o ?? 0)) < 0 - ) { - priority = spec; - } - } - return priority; -} - -export function preferredLanguages( - accept = "*", - provided?: string[], -): string[] { - const accepts = parseAcceptLanguage(accept); - - if (!provided) { - return accepts - .filter(isQuality) - .sort(compareSpecs) - .map((spec) => spec.full); - } - - const priorities = provided - .map((type, index) => getLanguagePriority(type, accepts, index)); - - return priorities - .filter(isQuality) - .sort(compareSpecs) - .map((priority) => provided[priorities.indexOf(priority)]!); -} +import { preferredEncodings } from "./_negotiation/encoding.ts"; +import { preferredLanguages } from "./_negotiation/language.ts"; +import { preferredMediaTypes } from "./_negotiation/media_type.ts"; /** * Returns an array of media types accepted by the request, in order of diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index 2d244bf..003979a 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -161,7 +161,8 @@ type="text" id="displayName" name="displayName" - maxlength="640" + maxlength="64" + autocomplete="off" value={data.user.displayName} /> @@ -194,7 +195,7 @@ onfocus={onFocus} onblur={onBlur} onkeydown={handleKeydown} - autocorrect="off" + autocomplete="off" spellcheck="false" role="combobox" aria-autocomplete="list" diff --git a/src/routes/bookmarks/[did=did]/+page.server.ts b/src/routes/bookmarks/[did=did]/+page.server.ts index edce2aa..859d0d4 100644 --- a/src/routes/bookmarks/[did=did]/+page.server.ts +++ b/src/routes/bookmarks/[did=did]/+page.server.ts @@ -1,5 +1,5 @@ import { isUserEvent } from "$lib/types"; -import { parseBookmark } from "$lib/valibot"; +import { type BookmarkData, parseBookmark } from "$lib/valibot"; import * as TID from "@atcute/tid"; import { type Actions, error, fail } from "@sveltejs/kit"; import type { PageServerLoad } from "./$types"; @@ -10,6 +10,29 @@ export const load: PageServerLoad = async ({ locals }) => { }; }; +const collator = new Intl.Collator("en", { + numeric: true, + sensitivity: "variant", +}); + +const parseFormData = (formData: FormData) => { + const data: BookmarkData = { + // @ts-ignore normalize url + url: URL.parse(formData.get("url"))?.href ?? "", + title: String(formData.get("title") ?? ""), + tags: formData.getAll("tags").filter(Boolean).map(String), + createdAt: new Date().toISOString(), + }; + if (data.tags) { + if (data.tags.length === 0) { + delete data.tags; + } else { + data.tags.sort((a, b) => collator.compare(a, b)); + } + } + return data; +}; + export const actions = { deleteBookmark: async (event) => { if (isUserEvent(event) === false) { @@ -26,11 +49,11 @@ export const actions = { }); if (result.ok === false) { return fail(400, { - action: "delete", + action: "deleteBookmark", error: "Failed to delete bookmark.", }); } - return { success: true }; + return { action: "deleteBookmark" }; }, createBookmark: async (event) => { if (isUserEvent(event) === false) { @@ -38,34 +61,36 @@ export const actions = { } const { user } = event.locals; const formData = await event.request.formData(); - formData.set("createdAt", new Date().toISOString()); - const data = Object.fromEntries(formData); + const data = parseFormData(formData); try { - // @ts-ignore normalize url - const parsed = URL.parse(formData.get("url")); - data.url = parsed?.href ?? data.url; const record = parseBookmark(data); const result = await user.client.post("com.atproto.repo.putRecord", { input: { + record, + rkey: TID.now(), repo: user.did, collection: "social.attic.bookmark.entity", - rkey: TID.now(), - record, }, }); if (result.ok === false) { throw new Error(); } - return { success: true }; + return { + record, + cid: result.data.cid, + uri: result.data.uri, + action: "createBookmark", + }; } catch (err) { let error = "Failed to create bookmark."; if (err instanceof Error) { error = err.message; } + data.url = String(formData.get("url") ?? ""); return fail(400, { - data: Object.fromEntries(formData), - action: "createBookmark", + data, error, + action: "createBookmark", }); } }, @@ -75,43 +100,47 @@ export const actions = { } const { user } = event.locals; const formData = await event.request.formData(); + const data = parseFormData(formData); + const rkey = String(formData.get("rkey") ?? ""); try { const response = await user.client.get("com.atproto.repo.getRecord", { params: { + rkey, repo: user.did, collection: "social.attic.bookmark.entity", - rkey: String(formData.get("rkey")), }, }); if (response.ok === false) { throw new Error(); } - const record = response.data.value; - // @ts-ignore normalize url - record.url = URL.parse(formData.get("url"))?.href ?? ""; - record.title = formData.get("title"); - parseBookmark(record); + let record = parseBookmark(response.data.value); + data.createdAt = record.createdAt; + record = parseBookmark(data); const result = await user.client.post("com.atproto.repo.putRecord", { input: { + rkey, + record, repo: user.did, collection: "social.attic.bookmark.entity", - rkey: String(formData.get("rkey")), - record, }, }); if (result.ok === false) { throw new Error(); } - return { success: true }; + return { action: "editBookmark", record }; } catch (err) { let error = "Failed to edit bookmark."; if (err instanceof Error) { error = err.message; } return fail(400, { - data: Object.fromEntries(formData), - action: "editBookmark", + rkey, error, + action: "editBookmark", + data: { + ...data, + url: String(formData.get("url") ?? ""), + }, }); } }, diff --git a/src/routes/bookmarks/[did=did]/+page.svelte b/src/routes/bookmarks/[did=did]/+page.svelte index 94a779c..4151fb3 100644 --- a/src/routes/bookmarks/[did=did]/+page.svelte +++ b/src/routes/bookmarks/[did=did]/+page.svelte @@ -1,11 +1,12 @@