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 @@