From cd67f6a5c5ec89d983c0f835f3e6bed54f939498 Mon Sep 17 00:00:00 2001 From: Bad Manners <me@badmanners.xyz> Date: Mon, 12 Aug 2024 21:38:16 -0300 Subject: [PATCH] Add qualifyLocalURLsInMarkdown() This will handle special links in the description and similar fields --- package-lock.json | 502 +++++++++++++++++- package.json | 12 +- src/components/CopyrightedCharacters.astro | 10 +- src/content/config.ts | 5 +- .../series/the-lost-of-the-marshes.yaml | 2 +- src/content/stories/rose-s-binge.md | 1 + src/content/tag-categories/8-other-kinks.yaml | 2 + src/i18n/index.ts | 11 +- src/layouts/PublishedContentLayout.astro | 24 +- src/pages/api/export-story/[...slug].ts | 225 +++----- src/pages/feed.xml.ts | 158 +++--- src/pages/stories/[...slug].astro | 3 +- src/pages/stories/[page].astro | 7 +- .../stories/the-lost-of-the-marshes.astro | 2 +- src/pages/tags.astro | 14 +- src/pages/tags/[slug].astro | 63 ++- src/utils/format_copyrighted_characters.ts | 11 +- src/utils/get_username_for_lang.ts | 2 +- src/utils/get_website_link_for_user.ts | 114 ++++ src/utils/qualify_local_urls_in_markdown.ts | 110 ++++ 20 files changed, 982 insertions(+), 296 deletions(-) create mode 100644 src/utils/get_website_link_for_user.ts create mode 100644 src/utils/qualify_local_urls_in_markdown.ts diff --git a/package-lock.json b/package-lock.json index 8dafd67..7c8dcc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,27 @@ { "name": "gallery-badmanners-xyz", - "version": "1.7.0", + "version": "1.7.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gallery-badmanners-xyz", - "version": "1.7.0", + "version": "1.7.1", "hasInstallScript": true, "dependencies": { "@astrojs/check": "^0.9.2", "@astrojs/rss": "^4.0.7", "@astrojs/tailwind": "^5.1.0", "@astropub/md": "^1.0.0", - "@tailwindcss/typography": "^0.5.13", - "astro": "^4.13.1", + "@tailwindcss/typography": "^0.5.14", + "astro": "^4.13.3", "astro-pagefind": "^1.6.0", "github-slugger": "^2.0.0", "marked": "^12.0.2", "pagefind": "^1.1.0", "reading-time": "^1.5.0", "sanitize-html": "^2.13.0", - "tailwindcss": "^3.4.7", + "tailwindcss": "^3.4.9", "tiny-decode": "^0.1.3", "typescript": "^5.5.4" }, @@ -32,8 +32,8 @@ "fetch-retry": "^6.0.0", "prettier": "^3.3.3", "prettier-plugin-astro": "^0.14.1", - "prettier-plugin-tailwindcss": "^0.6.5", - "tsx": "^4.16.5" + "prettier-plugin-tailwindcss": "^0.6.6", + "tsx": "^4.17.0" } }, "../astro-pagefind/packages/astro-pagefind": { @@ -936,6 +936,23 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz", + "integrity": "sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", @@ -1824,9 +1841,9 @@ } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.13.tgz", - "integrity": "sha512-ADGcJ8dX21dVVHIwTRgzrcunY6YY9uSlAHHGVKvkA+vLc5qLwEszvKts40lx7z0qc4clpjclwLeK5rVCV2P/uw==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.14.tgz", + "integrity": "sha512-ZvOCjUbsJBjL9CxQBn+VEnFpouzuKhxh2dH8xMIWHILL+HfOYtlAkWcyoon8LlzE53d2Yo6YO6pahKKNW3q1YQ==", "license": "MIT", "dependencies": { "lodash.castarray": "^4.4.0", @@ -2237,12 +2254,12 @@ } }, "node_modules/astro": { - "version": "4.13.1", - "resolved": "https://registry.npmjs.org/astro/-/astro-4.13.1.tgz", - "integrity": "sha512-VnMjAc+ykFsIVjgbu9Mt/EA1fMIcPMXbU89h3ATwGOzSIKDsQH72bDgfJkWiwk6u0OE90GeP5EPhAT28Twf9oA==", + "version": "4.13.3", + "resolved": "https://registry.npmjs.org/astro/-/astro-4.13.3.tgz", + "integrity": "sha512-MyhmM0v5sphiVwxAm5jjKxWeuPZijWPJ8Ajdign9QzEmLWSH8vUYIJWx/dWRQ6vF1I0jXrksoj3wtw5nzXt9nw==", "license": "MIT", "dependencies": { - "@astrojs/compiler": "^2.10.0", + "@astrojs/compiler": "^2.10.1", "@astrojs/internal-helpers": "0.4.1", "@astrojs/markdown-remark": "5.2.0", "@astrojs/telemetry": "3.1.0", @@ -2291,7 +2308,7 @@ "prompts": "^2.4.2", "rehype": "^13.0.1", "semver": "^7.6.3", - "shiki": "^1.12.0", + "shiki": "^1.12.1", "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "tsconfck": "^3.1.1", @@ -5658,9 +5675,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.5.tgz", - "integrity": "sha512-axfeOArc/RiGHjOIy9HytehlC0ZLeMaqY09mm8YCkMzznKiDkwFzOpBvtuhuv3xG5qB73+Mj7OCe2j/L1ryfuQ==", + "version": "0.6.6", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.6.tgz", + "integrity": "sha512-OPva5S7WAsPLEsOuOWXATi13QrCKACCiIonFgIR6V4lYv4QLp++UXVhZSzRbZxXGimkQtQT86CC6fQqTOybGng==", "dev": true, "license": "MIT", "engines": { @@ -5678,6 +5695,7 @@ "prettier-plugin-import-sort": "*", "prettier-plugin-jsdoc": "*", "prettier-plugin-marko": "*", + "prettier-plugin-multiline-arrays": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", "prettier-plugin-sort-imports": "*", @@ -5715,6 +5733,9 @@ "prettier-plugin-marko": { "optional": true }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, "prettier-plugin-organize-attributes": { "optional": true }, @@ -6568,9 +6589,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.7", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz", - "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==", + "version": "3.4.9", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", + "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -6790,13 +6811,13 @@ "optional": true }, "node_modules/tsx": { - "version": "4.16.5", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.5.tgz", - "integrity": "sha512-ArsiAQHEW2iGaqZ8fTA1nX0a+lN5mNTyuGRRO6OW3H/Yno1y9/t1f9YOI1Cfoqz63VAthn++ZYcbDP7jPflc+A==", + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.17.0.tgz", + "integrity": "sha512-eN4mnDA5UMKDt4YZixo9tBioibaMBpoxBkD+rIPAjVmYERSG0/dWEY1CEFuV89CgASlKL499q8AhmkMnnjtOJg==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "~0.21.5", + "esbuild": "~0.23.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -6809,6 +6830,437 @@ "fsevents": "~2.3.3" } }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz", + "integrity": "sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.0.tgz", + "integrity": "sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz", + "integrity": "sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.0.tgz", + "integrity": "sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz", + "integrity": "sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz", + "integrity": "sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz", + "integrity": "sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz", + "integrity": "sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz", + "integrity": "sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz", + "integrity": "sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz", + "integrity": "sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz", + "integrity": "sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz", + "integrity": "sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz", + "integrity": "sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz", + "integrity": "sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz", + "integrity": "sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz", + "integrity": "sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz", + "integrity": "sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz", + "integrity": "sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz", + "integrity": "sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz", + "integrity": "sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz", + "integrity": "sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz", + "integrity": "sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.23.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", + "integrity": "sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.0", + "@esbuild/android-arm": "0.23.0", + "@esbuild/android-arm64": "0.23.0", + "@esbuild/android-x64": "0.23.0", + "@esbuild/darwin-arm64": "0.23.0", + "@esbuild/darwin-x64": "0.23.0", + "@esbuild/freebsd-arm64": "0.23.0", + "@esbuild/freebsd-x64": "0.23.0", + "@esbuild/linux-arm": "0.23.0", + "@esbuild/linux-arm64": "0.23.0", + "@esbuild/linux-ia32": "0.23.0", + "@esbuild/linux-loong64": "0.23.0", + "@esbuild/linux-mips64el": "0.23.0", + "@esbuild/linux-ppc64": "0.23.0", + "@esbuild/linux-riscv64": "0.23.0", + "@esbuild/linux-s390x": "0.23.0", + "@esbuild/linux-x64": "0.23.0", + "@esbuild/netbsd-x64": "0.23.0", + "@esbuild/openbsd-arm64": "0.23.0", + "@esbuild/openbsd-x64": "0.23.0", + "@esbuild/sunos-x64": "0.23.0", + "@esbuild/win32-arm64": "0.23.0", + "@esbuild/win32-ia32": "0.23.0", + "@esbuild/win32-x64": "0.23.0" + } + }, "node_modules/type-fest": { "version": "2.19.0", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", diff --git a/package.json b/package.json index 4519d7e..9d7eb88 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "gallery-badmanners-xyz", "type": "module", - "version": "1.7.0", + "version": "1.7.1", "scripts": { "postinstall": "astro sync", "dev": "astro dev", @@ -19,15 +19,15 @@ "@astrojs/rss": "^4.0.7", "@astrojs/tailwind": "^5.1.0", "@astropub/md": "^1.0.0", - "@tailwindcss/typography": "^0.5.13", - "astro": "^4.13.1", + "@tailwindcss/typography": "^0.5.14", + "astro": "^4.13.3", "astro-pagefind": "^1.6.0", "github-slugger": "^2.0.0", "marked": "^12.0.2", "pagefind": "^1.1.0", "reading-time": "^1.5.0", "sanitize-html": "^2.13.0", - "tailwindcss": "^3.4.7", + "tailwindcss": "^3.4.9", "tiny-decode": "^0.1.3", "typescript": "^5.5.4" }, @@ -38,7 +38,7 @@ "fetch-retry": "^6.0.0", "prettier": "^3.3.3", "prettier-plugin-astro": "^0.14.1", - "prettier-plugin-tailwindcss": "^0.6.5", - "tsx": "^4.16.5" + "prettier-plugin-tailwindcss": "^0.6.6", + "tsx": "^4.17.0" } } diff --git a/src/components/CopyrightedCharacters.astro b/src/components/CopyrightedCharacters.astro index 6de2ad6..7d63011 100644 --- a/src/components/CopyrightedCharacters.astro +++ b/src/components/CopyrightedCharacters.astro @@ -18,15 +18,13 @@ const charactersPerUser = copyrightedCharacters ? await formatCopyrightedCharact charactersPerUser ? ( <section id="copyrighted-characters" aria-label={t(lang, "characters/copyrighted_characters_aria_label")}> <ul> - {charactersPerUser.map(([owner, characterList]) => ( + {charactersPerUser.map(({ user, characters }) => ( <CopyrightedCharactersItem - stringFunction={ - characterList[0] === "" - ? (user) => t(lang, "characters/all_characters_are_copyrighted_by", user) - : (user) => t(lang, "characters/characters_are_copyrighted_by", user, characterList) + stringFunction={(user) => + t(lang, "characters/characters_are_copyrighted_by", user, characters[0] === "" ? [] : characters) } > - <UserComponent lang={lang} user={owner} /> + <UserComponent lang={lang} user={user} /> </CopyrightedCharactersItem> ))} </ul> diff --git a/src/content/config.ts b/src/content/config.ts index 70089cf..8815165 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -217,10 +217,11 @@ const publishedContent = z.object({ // Types export type Lang = z.output<typeof lang>; -export type Website = keyof z.input<typeof websiteLinks>; +export type UserWebsite = keyof z.input<typeof websiteLinks>; export type GamePlatform = z.infer<typeof platform>; export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>; export type PublishedContent = z.infer<typeof publishedContent>; +export type PostWebsite = keyof PublishedContent["posts"]; // Content collections @@ -305,7 +306,7 @@ const seriesCollection = defineCollection({ schema: z.object({ // Required parameters name: z.string(), - url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`), + link: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"link" must be a local URL`), }), }); diff --git a/src/content/series/the-lost-of-the-marshes.yaml b/src/content/series/the-lost-of-the-marshes.yaml index dbdb08e..570ebbf 100644 --- a/src/content/series/the-lost-of-the-marshes.yaml +++ b/src/content/series/the-lost-of-the-marshes.yaml @@ -1,2 +1,2 @@ name: The Lost of the Marshes -url: /stories/the-lost-of-the-marshes +link: /stories/the-lost-of-the-marshes diff --git a/src/content/stories/rose-s-binge.md b/src/content/stories/rose-s-binge.md index 689649d..2966d9a 100644 --- a/src/content/stories/rose-s-binge.md +++ b/src/content/stories/rose-s-binge.md @@ -35,6 +35,7 @@ tags: - nudity - masturbation - plushie + - wardrobe malfunction - commission commissioner: dee-lumeni copyrightedCharacters: diff --git a/src/content/tag-categories/8-other-kinks.yaml b/src/content/tag-categories/8-other-kinks.yaml index ad1f679..0da71f6 100644 --- a/src/content/tag-categories/8-other-kinks.yaml +++ b/src/content/tag-categories/8-other-kinks.yaml @@ -23,3 +23,5 @@ tags: description: Scenarios where sexual consent is dubious. - name: plushie description: Scenarios with sexual action involving stuffed toys or plushie-like characters. + - name: wardrobe malfunction + description: Scenarios where a mishap happens with someone's clothes, such as accidentally revealing body parts and/or bursting open. diff --git a/src/i18n/index.ts b/src/i18n/index.ts index ef21b7a..ec36876 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -238,12 +238,11 @@ const UI_STRINGS = { }, "characters/characters_are_copyrighted_by": { en: (owner: string, charactersList: string[]) => - charactersList.length == 1 - ? `${charactersList[0]} is © ${owner}` - : `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`, - }, - "characters/all_characters_are_copyrighted_by": { - en: (owner: string) => `All characters are © ${owner}`, + charactersList.length == 0 + ? `All characters are © ${owner}` + : charactersList.length == 1 + ? `${charactersList[0]} is © ${owner}` + : `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`, }, // Tag-related strings "tag/total_works_with_tag": { diff --git a/src/layouts/PublishedContentLayout.astro b/src/layouts/PublishedContentLayout.astro index 2571500..96185fd 100644 --- a/src/layouts/PublishedContentLayout.astro +++ b/src/layouts/PublishedContentLayout.astro @@ -10,6 +10,7 @@ import CopyrightedCharacters from "../components/CopyrightedCharacters.astro"; import Prose from "../components/Prose.astro"; import MastodonComments from "../components/MastodonComments.astro"; import type { CopyrightedCharacters as CopyrightedCharactersType } from "../content/config"; +import { qualifyLocalURLsInMarkdown } from "../utils/qualify_local_urls_in_markdown"; interface RelatedContent { link: string; @@ -62,14 +63,16 @@ const categorizedTags = Object.fromEntries( ), ), ); +const description = await qualifyLocalURLsInMarkdown(props.description, props.lang); +const summary = props.summary && (await qualifyLocalURLsInMarkdown(props.summary, props.lang)); const tags = props.tags.map<{ id: string; name: string }>((tag) => { const tagSlug = slug(tag); if (!(tag in categorizedTags)) { - console.warn(`Tag "${tag}" doesn't have a category in the "tag-categories" collection`); + console.warn(`WARNING: Tag "${tag}" doesn't have a category in the "tag-categories" collection`); return { id: tagSlug, name: tag }; } if (categorizedTags[tag] == null) { - console.warn(`No "${props.lang}" translation for tag "${tag}"`); + console.warn(`WARNING: No "${props.lang}" translation for tag "${tag}"`); return { id: tagSlug, name: tag }; } return { id: tagSlug, name: categorizedTags[tag] }; @@ -111,7 +114,7 @@ const thumbnail = class="pointer-events-auto sticky top-6 flex rounded-full bg-white px-1 py-1 shadow-md dark:bg-black print:hidden" > <a - href={series ? series.data.url : props.labelReturnTo.link} + href={series ? series.data.link : props.labelReturnTo.link} class="text-link my-1 p-2" aria-labelled-by="label-return-to" > @@ -291,12 +294,12 @@ const thumbnail = {t(props.lang, "published_content/description")} </h2> <Prose> - <Markdown of={props.description} /> + <Markdown of={description} /> <CopyrightedCharacters copyrightedCharacters={props.copyrightedCharacters} lang={props.lang} /> </Prose> </section> { - props.summary ? ( + summary ? ( <section id="summary" class="px-2 font-serif" aria-describedby="title-summary"> <h2 id="title-summary" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> {t(props.lang, "published_content/summary")} @@ -307,7 +310,7 @@ const thumbnail = </summary> <div class="px-2 py-1"> <Prose> - <Markdown of={props.summary} /> + <Markdown of={summary} /> </Prose> </div> </details> @@ -417,10 +420,13 @@ const thumbnail = <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> {t(props.lang, "published_content/tags")} </h2> - <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2"> + <ul class="flex flex-wrap gap-x-2 gap-y-3 px-3"> {tags.map(({ id, name }) => ( - <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white print:bg-none"> - <a class="hover:underline focus:underline" href={`/tags/${id}`}> + <li> + <a + class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm hover:underline focus:underline dark:bg-bm-600 dark:text-white print:bg-none" + href={`/tags/${id}`} + > {name} </a> </li> diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts index ac57980..0a10199 100644 --- a/src/pages/api/export-story/[...slug].ts +++ b/src/pages/api/export-story/[...slug].ts @@ -1,14 +1,16 @@ import type { APIRoute, GetStaticPaths } from "astro"; import { getCollection, type CollectionEntry, getEntries } from "astro:content"; -import type { Website } from "../../../content/config"; -import { t, type Lang } from "../../../i18n"; +import type { PostWebsite } from "../../../content/config"; +import { t } from "../../../i18n"; import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters"; import { markdownToBbcode } from "../../../utils/markdown_to_bbcode"; import { getUsernameForLang } from "../../../utils/get_username_for_lang"; import { isAnonymousUser } from "../../../utils/is_anonymous_user"; +import { qualifyLocalURLsInMarkdown } from "../../../utils/qualify_local_urls_in_markdown"; +import { getWebsiteLinkForUser } from "../../../utils/get_website_link_for_user"; interface ExportWebsiteInfo { - website: Website; + website: PostWebsite; exportFormat: "bbcode" | "markdown"; } @@ -22,76 +24,6 @@ const WEBSITE_LIST = [ type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never; -function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string { - const link = user.data.links[website]; - if (link && "username" in link && link.username) { - return link.username; - } - throw new Error(`Cannot get "${website}" username for user "${user.id}"`); -} - -function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): boolean { - const { preferredLink } = user.data; - return !preferredLink || preferredLink == website; -} - -function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, lang: Lang): string { - const { links, preferredLink } = user.data; - switch (website) { - case "eka": - if ("eka" in links) { - return `:icon${getUsernameForWebsite(user, "eka")}:`; - } - break; - case "furaffinity": - if ("furaffinity" in links) { - return `:icon${getUsernameForWebsite(user, "furaffinity")}:`; - } - break; - case "weasyl": - if ("weasyl" in links) { - return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`; - } else if (isPreferredWebsite(user, "furaffinity")) { - return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`; - } else if (isPreferredWebsite(user, "inkbunny")) { - return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`; - } else if (isPreferredWebsite(user, "sofurry")) { - return `<sf:${getUsernameForWebsite(user, "sofurry")}>`; - } - break; - case "inkbunny": - if ("inkbunny" in links) { - return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`; - } else if (isPreferredWebsite(user, "furaffinity")) { - return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`; - } else if (isPreferredWebsite(user, "sofurry")) { - return `[sf]${getUsernameForWebsite(user, "sofurry")}[/sf]`; - } else if (isPreferredWebsite(user, "weasyl")) { - return `[weasyl]${getUsernameForWebsite(user, "weasyl")}[/weasyl]`; - } - break; - case "sofurry": - if ("sofurry" in links) { - return `:icon${getUsernameForWebsite(user, "sofurry")}:`; - } else if (isPreferredWebsite(user, "furaffinity")) { - return `fa!${getUsernameForWebsite(user, "furaffinity")}`; - } else if (isPreferredWebsite(user, "inkbunny")) { - return `ib!${getUsernameForWebsite(user, "inkbunny")}`; - } - break; - default: - const unknown: never = website; - throw new Error(`Unhandled export website "${unknown}"`); - } - if (preferredLink) { - const preferred = links[preferredLink]!; - return `[${getUsernameForLang(user, lang)}](${preferred.link})`; - } - throw new Error( - `No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`, - ); -} - type Props = { story: CollectionEntry<"stories">; }; @@ -111,18 +43,21 @@ export const getStaticPaths: GetStaticPaths = async () => { }; export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) => { - const { lang } = story.data; - const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters); - const authorsList = await getEntries(story.data.authors); - const commissionersList = story.data.commissioner && (await getEntries(story.data.commissioner)); - const requestersList = story.data.requester && (await getEntries(story.data.requester)); + try { + const { lang } = story.data; + const copyrightedCharacters = await formatCopyrightedCharacters(story.data.copyrightedCharacters); + const authorsList = await getEntries(story.data.authors); + const commissionersList = story.data.commissioner && (await getEntries(story.data.commissioner)); + const requestersList = story.data.requester && (await getEntries(story.data.requester)); - const description = Object.fromEntries( - WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => { - const u = (user: CollectionEntry<"users">) => - isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang); - const storyDescription = ( - [ + const description = await Promise.all( + WEBSITE_LIST.map(async ({ website, exportFormat }) => { + const exportWebsite: ExportWebsiteName = website; + const u = (user: CollectionEntry<"users">) => + isAnonymousUser(user) + ? getUsernameForLang(user, lang) + : getWebsiteLinkForUser(user, exportWebsite, (user) => getUsernameForLang(user, lang)); + const storyDescription = await [ story.data.description, `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`, t( @@ -142,62 +77,76 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) = "export_story/commissioned_by", commissionersList.map((commissioner) => u(commissioner)), ), - ...copyrightedCharacters.map(([user, characterList]) => - characterList[0] == "" - ? t(lang, "characters/all_characters_are_copyrighted_by", u(user)) - : t(lang, "characters/characters_are_copyrighted_by", u(user), characterList), + ...copyrightedCharacters.map(({ user, characters }) => + t(lang, "characters/characters_are_copyrighted_by", u(user), characters[0] == "" ? [] : characters), ), - ].filter((data) => data) as string[] - ) - .join("\n\n") - .replaceAll( - /\[([^\]]+)\]\((\/[^\)]+)\)/g, - (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`, - ); - switch (exportFormat) { - case "bbcode": - return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")]; - case "markdown": - return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()]; - default: - const unknown: never = exportFormat; - throw new Error(`Unknown export format "${unknown}"`); - } - }), - ); + ].reduce(async (promise, data) => { + if (!data) { + return promise; + } + const acc = await promise; + const newData = await qualifyLocalURLsInMarkdown(data, lang, site, exportWebsite); + return acc ? `${acc}\n\n${newData}` : newData; + }, Promise.resolve("")); + switch (exportFormat) { + case "bbcode": + return { exportWebsite, description: markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n") }; + case "markdown": + return { exportWebsite, description: storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim() }; + default: + const unknown: never = exportFormat; + throw new Error(`Unknown export format "${unknown}"`); + } + }), + ); - const storyHeader = - `${story.data.title}\n` + - `${t( - lang, - "story/authors", - authorsList.map((author) => getUsernameForLang(author, lang)), - )}\n` + - (requestersList - ? `${t( - lang, - "story/requested_by", - requestersList.map((requester) => getUsernameForLang(requester, lang)), - )}\n` - : "") + - (commissionersList - ? `${t( - lang, - "story/commissioned_by", - commissionersList.map((commissioner) => getUsernameForLang(commissioner, lang)), - )}\n` - : ""); + const storyHeader = + `${story.data.title}\n` + + `${t( + lang, + "story/authors", + authorsList.map((author) => getUsernameForLang(author, lang)), + )}\n` + + (requestersList + ? `${t( + lang, + "story/requested_by", + requestersList.map((requester) => getUsernameForLang(requester, lang)), + )}\n` + : "") + + (commissionersList + ? `${t( + lang, + "story/commissioned_by", + commissionersList.map((commissioner) => getUsernameForLang(commissioner, lang)), + )}\n` + : ""); - const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}` - .replaceAll(/\n\n\n+/g, "\n\n") - .trim(); + const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll(/\\([=*])/g, "$1")}` + .replaceAll(/\n\n\n+/g, "\n\n") + .trim(); - return new Response( - JSON.stringify({ - story: storyText, - description, - thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null, - }), - { headers: { "Content-Type": "application/json; charset=utf-8" } }, - ); + return new Response( + JSON.stringify({ + story: storyText, + description: description.reduce( + (acc, item) => { + acc[item.exportWebsite] = item.description; + return acc; + }, + {} as Record<PostWebsite, string>, + ), + thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null, + }), + { headers: { "Content-Type": "application/json; charset=utf-8" } }, + ); + } catch (e) { + return new Response( + JSON.stringify({ + message: (e as Error).message ?? null, + stack: (e as Error).stack ?? null, + }), + { status: 500, headers: { "Content-Type": "application/json; charset=utf-8" } }, + ); + } }; diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts index 24165db..554eee0 100644 --- a/src/pages/feed.xml.ts +++ b/src/pages/feed.xml.ts @@ -6,6 +6,7 @@ import sanitizeHtml from "sanitize-html"; import { t, type Lang } from "../i18n"; import { markdownToPlaintext } from "../utils/markdown_to_plaintext"; import { getUsernameForLang } from "../utils/get_username_for_lang"; +import { qualifyLocalURLsInMarkdown } from "../utils/qualify_local_urls_in_markdown"; type FeedItem = RSSFeedItem & Required<Pick<RSSFeedItem, "title" | "pubDate" | "link" | "description" | "categories" | "content">>; @@ -28,6 +29,81 @@ const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => { return userName; }; +async function storyFeedItem( + site: URL | undefined, + data: EntryWithPubDate<"stories">["data"], + slug: CollectionEntry<"stories">["slug"], + body: string, +): Promise<FeedItem> { + return { + title: `New story! "${data.title}"`, + pubDate: toNoonUTCDate(data.pubDate), + link: `/stories/${slug}`, + description: + `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll( + /[\n ]+/g, + " ", + ), + categories: ["story"], + content: sanitizeHtml( + `<h1>${data.title}</h1>` + + `<p>${t( + data.lang, + "story/authors", + (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)), + )}</p>` + + (data.requester + ? `<p>${t( + data.lang, + "story/requested_by", + (await getEntries(data.requester)).map((requester) => getLinkForUser(requester, data.lang)), + )}</p>` + : "") + + (data.commissioner + ? `<p>${t( + data.lang, + "story/commissioned_by", + (await getEntries(data.commissioner)).map((commissioner) => getLinkForUser(commissioner, data.lang)), + )}</p>` + : "") + + `<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` + + `<hr>${await markdown(body)}` + + `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`, + ), + }; +} + +async function gameFeedItem( + site: URL | undefined, + data: EntryWithPubDate<"games">["data"], + slug: CollectionEntry<"games">["slug"], + body: string, +): Promise<FeedItem> { + return { + title: `New game! "${data.title}"`, + pubDate: toNoonUTCDate(data.pubDate), + link: `/games/${slug}`, + description: + `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`.replaceAll( + /[\n ]+/g, + " ", + ), + categories: ["game"], + content: sanitizeHtml( + `<h1>${data.title}</h1>` + + `<p>${t( + data.lang, + "story/authors", + (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)), + )}</p>` + + `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` + + `<hr><p><em>${data.contentWarning}</em></p>` + + `<hr>${await markdown(body)}` + + `<hr>${await markdown(await qualifyLocalURLsInMarkdown(data.description, data.lang, site))}`, + ), + }; +} + export const GET: APIRoute = async ({ site }) => { const stories = ( (await getCollection( @@ -47,75 +123,21 @@ export const GET: APIRoute = async ({ site }) => { title: "Gallery | Bad Manners", description: "Stories, games, and (possibly) more by Bad Manners", site: site!, - items: [ - await Promise.all( - stories.map<Promise<FeedItem>>(async ({ data, slug, body }) => ({ - title: `New story! "${data.title}"`, - pubDate: toNoonUTCDate(data.pubDate), - link: `/stories/${slug}`, - description: - `${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}\n\n${markdownToPlaintext(data.description)}`.replaceAll( - /[\n ]+/g, - " ", - ), - categories: ["story"], - content: sanitizeHtml( - `<h1>${data.title}</h1>` + - `<p>${t( - data.lang, - "story/authors", - (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)), - )}</p>` + - (data.requester - ? `<p>${t( - data.lang, - "story/requested_by", - (await getEntries(data.requester)).map((requester) => getLinkForUser(requester, data.lang)), - )}</p>` - : "") + - (data.commissioner - ? `<p>${t( - data.lang, - "story/commissioned_by", - (await getEntries(data.commissioner)).map((commissioner) => - getLinkForUser(commissioner, data.lang), - ), - )}</p>` - : "") + - `<hr><p><em>${t(data.lang, "story/warnings", data.wordCount, data.contentWarning)}</em></p>` + - `<hr>${await markdown(body)}` + - `<hr>${await markdown(data.description)}`, - ), + items: await Promise.all( + [ + stories.map(({ data, slug, body }) => ({ + date: data.pubDate, + fn: () => storyFeedItem(site, data, slug, body), })), - ), - await Promise.all( - games.map<Promise<FeedItem>>(async ({ data, slug, body }) => ({ - title: `New game! "${data.title}"`, - pubDate: toNoonUTCDate(data.pubDate), - link: `/games/${slug}`, - description: - `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)}\n\n${markdownToPlaintext(data.description)}`.replaceAll( - /[\n ]+/g, - " ", - ), - categories: ["game"], - content: sanitizeHtml( - `<h1>${data.title}</h1>` + - `<p>${t( - data.lang, - "story/authors", - (await getEntries(data.authors)).map((author) => getLinkForUser(author, data.lang)), - )}</p>` + - `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` + - `<hr><p><em>${data.contentWarning}</em></p>` + - `<hr>${await markdown(body)}` + - `<hr>${await markdown(data.description)}`, - ), + games.map(({ data, slug, body }) => ({ + date: data.pubDate, + fn: () => gameFeedItem(site, data, slug, body), })), - ), - ] - .flat() - .sort((a, b) => b.pubDate.getTime() - a.pubDate.getTime()) - .slice(0, MAX_ITEMS), + ] + .flat() + .sort((a, b) => b.date.getTime() - a.date.getTime()) + .slice(0, MAX_ITEMS) + .map((value) => value.fn()), + ), }); }; diff --git a/src/pages/stories/[...slug].astro b/src/pages/stories/[...slug].astro index 2f58526..51d4e58 100644 --- a/src/pages/stories/[...slug].astro +++ b/src/pages/stories/[...slug].astro @@ -22,7 +22,8 @@ const story = Astro.props; const readingTime = getReadingTime(story.body); if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 150) { console.warn( - `"wordCount" differs greatly from actual word count for published story ${story.data.title} ("${story.slug}") ` + + `WARNING: "wordCount" differs greatly from actual word count for published story ` + + `${story.data.title} ("${story.slug}") ` + `(expected ~${story.data.wordCount}, found ${readingTime.words})`, ); } diff --git a/src/pages/stories/[page].astro b/src/pages/stories/[page].astro index 01c098a..ca11c38 100644 --- a/src/pages/stories/[page].astro +++ b/src/pages/stories/[page].astro @@ -1,9 +1,8 @@ --- import type { GetStaticPaths, Page } from "astro"; import { Image } from "astro:assets"; -import { getCollection } from "astro:content"; +import { getCollection, type CollectionEntry } from "astro:content"; import GalleryLayout from "../../layouts/GalleryLayout.astro"; -import type { CollectionEntry } from "astro:content"; import { t } from "../../i18n"; type StoryWithPubDate = CollectionEntry<"stories"> & { data: { pubDate: Date } }; @@ -51,7 +50,7 @@ const totalPages = Math.ceil(page.total / page.size); ) : ( <a class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500" - href={page.url.current.replace(`/${page.currentPage}`, `/${p + 1}`)} + href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p + 1}`)} > {p + 1} </a> @@ -114,7 +113,7 @@ const totalPages = Math.ceil(page.total / page.size); ) : ( <a class="text-link border-r border-stone-400 px-2 py-1 underline dark:border-stone-500" - href={page.url.current.replace(`/${page.currentPage}`, `/${p + 1}`)} + href={page.url.current.replace(`/stories/${page.currentPage}`, `/stories/${p + 1}`)} > {p + 1} </a> diff --git a/src/pages/stories/the-lost-of-the-marshes.astro b/src/pages/stories/the-lost-of-the-marshes.astro index 5777dd2..acda096 100644 --- a/src/pages/stories/the-lost-of-the-marshes.astro +++ b/src/pages/stories/the-lost-of-the-marshes.astro @@ -24,7 +24,7 @@ const mainChaptersWithSummaries = mainChapters.filter((story) => story.data.summ <meta slot="head-description" property="og:description" - content="Bad Manners || The story of Quince, Nikili, and Suu." + content="The Lost of the Marshes || The story of Quince, Nikili, and Suu." /> <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">{series.data.name}</h1> <p class="my-4">This is the main hub for the story of Quince, Nikili, and Suu, as well as all bonus content.</p> diff --git a/src/pages/tags.astro b/src/pages/tags.astro index 8c4577e..5373f99 100644 --- a/src/pages/tags.astro +++ b/src/pages/tags.astro @@ -64,7 +64,7 @@ const categorizedTags = tagCategories if (uncategorizedTagsSet.size > 0) { const tagList = [...uncategorizedTagsSet]; - console.warn("The following tags have no category:", tagList); + console.warn("WARNING: The following tags have no category:", tagList); // categorizedTags.push(["Uncategorized tags", "others", tagList]); } --- @@ -83,7 +83,7 @@ if (uncategorizedTagsSet.size > 0) { { seriesCollection.map((series) => ( <li> - <a class="text-link underline" href={series.data.url}> + <a class="text-link underline" href={series.data.link}> {series.data.name} </a> </li> @@ -98,10 +98,14 @@ if (uncategorizedTagsSet.size > 0) { <h2 id={`category-${categoryId}`} class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"> {category} </h2> - <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2"> + <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-3 px-3"> {tagList.map(({ id, name, description }) => ( - <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white"> - <a class="hover:underline focus:underline" href={`/tags/${id}`} title={description}> + <li> + <a + class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm hover:underline focus:underline dark:bg-bm-600 dark:text-white" + href={`/tags/${id}`} + title={description} + > {name} </a> </li> diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro index 67278cb..4343132 100644 --- a/src/pages/tags/[slug].astro +++ b/src/pages/tags/[slug].astro @@ -6,8 +6,8 @@ import { Markdown } from "@astropub/md"; import { slug } from "github-slugger"; import GalleryLayout from "../../layouts/GalleryLayout.astro"; import Prose from "../../components/Prose.astro"; -import { t } from "../../i18n"; -import { DEFAULT_LANG } from "../../i18n"; +import { t, DEFAULT_LANG } from "../../i18n"; +import { qualifyLocalURLsInMarkdown } from "../../utils/qualify_local_urls_in_markdown"; type EntryWithPubDate<C extends CollectionKey> = CollectionEntry<C> & { data: { pubDate: Date } }; @@ -47,11 +47,11 @@ export const getStaticPaths: GetStaticPaths = async () => { category.data.tags.forEach(({ name, description, related }) => { related = related.filter((relatedTag) => { if (relatedTag == name) { - console.warn(`Tag "${name}" should not have itself as a related tag; removing`); + console.warn(`WARNING: Tag "${name}" should not have itself as a related tag; removing...`); return false; } if (!tags.has(relatedTag)) { - console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`); + console.warn(`WARNING: Tag "${name}" has an unknown related tag "${relatedTag}"; removing...`); return false; } return true; @@ -88,23 +88,34 @@ export const getStaticPaths: GetStaticPaths = async () => { })); }; -const { tag, description, stories, games, related } = Astro.props; +const { props } = Astro; +const description = props.description && (await qualifyLocalURLsInMarkdown(props.description, DEFAULT_LANG)); if (!description) { - console.warn(`Tag "${tag}" has no description`); + console.warn(`WARNING: Tag "${props.tag}" has no description`); } -const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stories.length, games.length); +const totalWorksWithTag = t( + DEFAULT_LANG, + "tag/total_works_with_tag", + props.tag, + props.stories.length, + props.games.length, +); --- -<GalleryLayout pageTitle={`Works tagged "${tag}"`}> - <meta slot="head-description" content={`Bad Manners || ${totalWorksWithTag || tag}`} property="og:description" /> - <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{tag}"</h1> +<GalleryLayout pageTitle={`Works tagged "${props.tag}"`}> + <meta + slot="head-description" + content={`Bad Manners || ${totalWorksWithTag || props.tag}`} + property="og:description" + /> + <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{props.tag}"</h1> <div class="my-4"> <Prose> {description ? <Markdown of={description} /> : null} { - related?.length ? ( + props.related?.length ? ( <p - set:html={`See also: ${related.map((relatedTag) => `<a href="/tags/${slug(relatedTag)}">${relatedTag}</a>`).join(", ")}.`} + set:html={`See also: ${props.related.map((relatedTag) => `<a href="/tags/${slug(relatedTag)}">${relatedTag}</a>`).join(", ")}.`} /> ) : null } @@ -112,13 +123,13 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori </Prose> </div> { - stories.length > 0 && ( + props.stories.length > 0 && ( <section class="my-2" aria-labelledby="content-stories"> <h2 id="content-stories" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"> Stories </h2> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> - {stories.map((story) => ( + {props.stories.map((story) => ( <li class="break-inside-avoid"> <a class="text-link hover:underline focus:underline" @@ -135,7 +146,17 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori /> </div> ) : null} - <div class="max-w-48 text-sm">{story.data.title}</div> + <div class="max-w-[192px] text-sm"> + <span>{story.data.title}</span> + <br /> + <span class="italic"> + {story.data.pubDate.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + </span> + </div> </a> </li> ))} @@ -144,13 +165,13 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori ) } { - games.length > 0 && ( + props.games.length > 0 && ( <section class="my-2" aria-labelledby="content-games"> <h2 id="content-games" class="p-2 text-xl font-semibold text-stone-800 dark:text-stone-100"> Games </h2> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> - {games.map((game) => ( + {props.games.map((game) => ( <li class="break-inside-avoid"> <a class="text-link hover:underline focus:underline" @@ -167,7 +188,13 @@ const totalWorksWithTag = t(DEFAULT_LANG, "tag/total_works_with_tag", tag, stori /> </div> ) : null} - <div class="max-w-48 text-sm">{game.data.title}</div> + <div class="max-w-[192px] text-sm"> + <span>{game.data.title}</span> + <br /> + <span class="italic"> + {game.data.pubDate.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} + </span> + </div> </a> </li> ))} diff --git a/src/utils/format_copyrighted_characters.ts b/src/utils/format_copyrighted_characters.ts index cd3ff14..31a9bbf 100644 --- a/src/utils/format_copyrighted_characters.ts +++ b/src/utils/format_copyrighted_characters.ts @@ -7,14 +7,15 @@ export async function formatCopyrightedCharacters(copyrightedCharacters: Copyrig Object.keys(copyrightedCharacters).reduce( (acc, character) => { const user = copyrightedCharacters[character]; - if (!(user.id in acc)) { - acc[user.id] = [getEntry(user), []]; + if (user.id in acc) { + acc[user.id][1].push(character); + } else { + acc[user.id] = [getEntry(user), [character]]; } - acc[user.id][1].push(character); return acc; }, - {} as Record<string, [Promise<CollectionEntry<"users">>, string[]]>, + {} as Record<CollectionEntry<"users">["id"], [Promise<CollectionEntry<"users">>, string[]]>, ), - ).map(async ([userPromise, characters]) => [await userPromise, characters] as [CollectionEntry<"users">, string[]]), + ).map(async ([userPromise, characters]) => ({ user: await userPromise, characters })), ); } diff --git a/src/utils/get_username_for_lang.ts b/src/utils/get_username_for_lang.ts index eef4f74..a81328a 100644 --- a/src/utils/get_username_for_lang.ts +++ b/src/utils/get_username_for_lang.ts @@ -10,7 +10,7 @@ export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): throw new Error(`No "${lang}" translation for user "${user.id}"`); } if (lang !== DEFAULT_LANG) { - console.warn(`Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default`); + console.warn(`WARNING: Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default...`); } return name; } diff --git a/src/utils/get_website_link_for_user.ts b/src/utils/get_website_link_for_user.ts new file mode 100644 index 0000000..3539ce2 --- /dev/null +++ b/src/utils/get_website_link_for_user.ts @@ -0,0 +1,114 @@ +import type { CollectionEntry } from "astro:content"; +import { DEFAULT_LANG, type UserWebsite } from "../content/config"; +import { getUsernameForLang } from "./get_username_for_lang"; + +type WebsiteUserData<W extends UserWebsite> = CollectionEntry<"users">["data"]["links"][W]; + +function getUserDataForWebsite<W extends UserWebsite>(user: CollectionEntry<"users">, website: W) { + const link: WebsiteUserData<W> | undefined = user.data.links[website]; + if (link) { + return link; + } + throw new Error(`Cannot get "${website}" data for user "${user.id}"`); +} + +const isPreferredWebsite = (user: CollectionEntry<"users">, website: UserWebsite) => + !!user.data.preferredLink && user.data.preferredLink == website; + +/** + * Format a user to the special string that references the user in a specified website - + * for example, by adding the `@` in front of a Twitter handle. + * Otherwise, it will be formatted as a Markdown link to the user's preferred link. + * @param user User to format. + * @param website Which website to format with. + * The function will attempt to match the user's link for the given website, + * or try to use any special formatting rules corresponding with the user's preferred link. + * @param usernameFn Optional function that labels the user's preferred link. + * By default, this will be a function that returns the username in English. + * @returns + */ +export function getWebsiteLinkForUser( + user: CollectionEntry<"users">, + website: UserWebsite | undefined, + usernameFn: (user: CollectionEntry<"users">) => string = (user) => getUsernameForLang(user, DEFAULT_LANG), +): string { + if (website === "website") { + website = undefined; + } + const { links, preferredLink } = user.data; + switch (website) { + case undefined: + break; + case "eka": + if ("eka" in links) { + return `:icon${getUserDataForWebsite(user, "eka").username}:`; + } + break; + case "furaffinity": + if ("furaffinity" in links) { + return `:icon${getUserDataForWebsite(user, "furaffinity").username}:`; + } + break; + case "weasyl": + if ("weasyl" in links) { + return `<!~${getUserDataForWebsite(user, "weasyl").username.replaceAll(" ", "")}>`; + } else if (isPreferredWebsite(user, "furaffinity")) { + return `<fa:${getUserDataForWebsite(user, "furaffinity").username.replaceAll("_", "")}>`; + } else if (isPreferredWebsite(user, "inkbunny")) { + return `<ib:${getUserDataForWebsite(user, "inkbunny").username}>`; + } else if (isPreferredWebsite(user, "sofurry")) { + return `<sf:${getUserDataForWebsite(user, "sofurry").username}>`; + } + break; + case "inkbunny": + if ("inkbunny" in links) { + return `[iconname]${getUserDataForWebsite(user, "inkbunny").username}[/iconname]`; + } else if (isPreferredWebsite(user, "furaffinity")) { + return `[fa]${getUserDataForWebsite(user, "furaffinity").username}[/fa]`; + } else if (isPreferredWebsite(user, "sofurry")) { + return `[sf]${getUserDataForWebsite(user, "sofurry").username}[/sf]`; + } else if (isPreferredWebsite(user, "weasyl")) { + return `[weasyl]${getUserDataForWebsite(user, "weasyl").username}[/weasyl]`; + } + break; + case "sofurry": + if ("sofurry" in links) { + return `:icon${getUserDataForWebsite(user, "sofurry").username}:`; + } else if (isPreferredWebsite(user, "furaffinity")) { + return `fa!${getUserDataForWebsite(user, "furaffinity").username}`; + } else if (isPreferredWebsite(user, "inkbunny")) { + return `ib!${getUserDataForWebsite(user, "inkbunny").username}`; + } + break; + case "twitter": + if ("twitter" in links) { + return `@${getUserDataForWebsite(user, "twitter").username}`; + } + break; + case "mastodon": + if ("mastodon" in links) { + const { handle, instance } = getUserDataForWebsite(user, "mastodon"); + return `@${handle}@${instance}`; + } + break; + case "bluesky": + if ("bluesky" in links) { + return `@${getUserDataForWebsite(user, "bluesky").username}`; + } + break; + case "itaku": + if ("itaku" in links) { + return `@${getUserDataForWebsite(user, "itaku").username}`; + } + break; + default: + const unknown: never = website; + throw new Error(`Unhandled export website "${unknown}"`); + } + if (preferredLink) { + return `[${usernameFn(user)}](${links[preferredLink]!.link})`; + } + throw new Error( + `No matching${website ? ` "${website}" ` : " "}link for user "${user.id}". (hint: consider setting their "preferredLink" property)`, + ); +} diff --git a/src/utils/qualify_local_urls_in_markdown.ts b/src/utils/qualify_local_urls_in_markdown.ts new file mode 100644 index 0000000..559a0d8 --- /dev/null +++ b/src/utils/qualify_local_urls_in_markdown.ts @@ -0,0 +1,110 @@ +import { getCollection, getEntry } from "astro:content"; +import type { Lang, PostWebsite } from "../content/config"; +import { getWebsiteLinkForUser } from "./get_website_link_for_user"; +import { getUsernameForLang } from "./get_username_for_lang"; + +type MatchGroups = + | { + text: string; + link: string; + } + | { + text: string; + link: string; + contentPrefix: "stories" | "games" | "users"; + slug: string; + }; + +const LOCAL_URL_REGEX = + /\[(?<text>[^\]]*)\]\((?<link>(?:\/(?<contentPrefix>stories|games|users)(?!\/?\)|\/[1-9]\d*\/?\))\/(?<slug>[^\)]+))|(?:\/[^\)]+?))\/?\)/g; + +const SERIES_URLS_REGEX_PROMISE = getCollection("series").then( + (series) => new RegExp(`^(${series.map(({ data }) => data.link.replace(/\/$/, "")).join("|")})\/?$`), +); + +/** + * Given a Markdown text, finds any local URLs (eg. `[click me](/stories/foo)`) and replaces them with the appropriate + * fully qualified link (eg. `[click me](https://example.com/stories/foo)`). + * @param originalText The Markdown text to modify. + * @param lang The language for translating usernames. + * @param site The base URL of the gallery, to append to the start of local links and permalinks. + * If undefined, this function will only replace `/users/...` links, while still validating the others. + * @param website Which website the Markdown text will be rendered to. If defined, any content links + * (stories, games, etc.) will be replaced by the appropriate link on that website whenever possible. + * If undefined, this function will only replace `/users/...` links (and permalinks if `site` is set), + * while still validating the others. + */ +export async function qualifyLocalURLsInMarkdown(originalText: string, lang: Lang, site?: URL, website?: PostWebsite) { + const replacements: string[] = []; + for (const match of originalText.matchAll(new RegExp(LOCAL_URL_REGEX))) { + const groups = match.groups as MatchGroups | undefined; + if (groups?.text === undefined || groups?.link === undefined) { + throw new Error(`Cannot qualify invalid local URL ${match[0]}`); + } + const { text, link } = groups; + // Any links that match a series `link` are handled separately. + if ((await SERIES_URLS_REGEX_PROMISE).test(link)) { + replacements.push(site ? `[${text}](${new URL(link, site).toString()})` : match[0]); + continue; + } + // Check if this is a special link (story, game, or user) + if ("contentPrefix" in groups && groups.contentPrefix) { + const { contentPrefix, slug } = groups; + switch (contentPrefix) { + case "stories": + const story = await getEntry("stories", slug); + if (!story) { + throw new Error(`Couldn't find story with slug "${slug}"`); + } + if (typeof website === "string" && story.data.posts[website]?.link) { + replacements.push(`[${text}](${story.data.posts[website].link})`); + continue; + } + break; + case "games": + const game = await getEntry("games", slug); + if (!game) { + throw new Error(`Couldn't find game with slug "${slug}"`); + } + if (typeof website === "string" && game.data.posts[website]?.link) { + replacements.push(`[${text}](${game.data.posts[website].link})`); + continue; + } + break; + case "users": + const user = await getEntry("users", slug); + if (!user) { + throw new Error(`Couldn't find user with id "${slug}"`); + } + // If there's a label in the link, use that if possible + if (text) { + if (typeof website === "string" && user.data.links[website]?.link) { + replacements.push(`[${text}](${user.data.links[website].link})`); + continue; + } else if (user.data.preferredLink) { + replacements.push(`[${text}](${user.data.links[user.data.preferredLink]!.link})`); + continue; + } + } + // Otherwise (i.e. label is empty), use the username for the website + replacements.push(getWebsiteLinkForUser(user, website, (user) => getUsernameForLang(user, lang))); + continue; + default: + const unknown: never = contentPrefix; + console.warn(`WARNING: Unknown local link qualifier ${unknown}; ignoring...`); + } + } + replacements.push(website && site ? `[${text}](${new URL(link, site).toString()})` : match[0]); + } + const newText = originalText.replaceAll(new RegExp(LOCAL_URL_REGEX), () => { + const replacement = replacements.shift(); + if (!replacement) { + throw new Error(`Replacements array length didn't match length of regex matches! (too few elements)`); + } + return replacement; + }); + if (replacements.length > 0) { + throw new Error(`Replacements array length didn't match length of regex matches! (too many elements)`); + } + return newText; +}