From 7bb8a952ef1bc2ab64feb5f645010c74c9ad66a6 Mon Sep 17 00:00:00 2001 From: Bad Manners <me@badmanners.xyz> Date: Wed, 7 Aug 2024 19:25:50 -0300 Subject: [PATCH] Several minor improvements to typing and misc. - Improved schema validation - Move username parsing and other validators to schema types - Fix astro check command - Add JSON/YAML schema validation for data collections - Update licenses - Remove deployment script in favor of rsync - Prevent unsanitized input in export-story script - Change "eng" language to "en", per BCP47 - Clean up i18n keys and add aria attributes - Improve MastodonComments behavior on no-JS browsers --- .vscode/extensions.json | 7 +- .vscode/settings.json | 19 + LICENSE | 21 - LICENSE.md | 1 + README.md | 19 +- astro.config.mjs | 1 + examples/game.md | 2 +- examples/story.md | 2 +- examples/user.yaml | 2 +- package-lock.json | 307 ++++++------- package.json | 17 +- .../licenses.txt.ts => public/licenses.txt | 18 +- scripts/deploy-lftp.ts | 62 --- scripts/export-story.ts | 11 +- .../thumbnails/bm_20_playing_it_safe.png | Bin 22894 -> 20821 bytes src/components/AgeRestrictedModal.astro | 10 +- src/components/Authors.astro | 12 +- src/components/Commissioners.astro | 12 +- src/components/CopyrightedCharacters.astro | 6 +- src/components/DarkModeScript.astro | 6 +- src/components/MastodonComments.astro | 271 +++++++----- src/components/Requesters.astro | 12 +- src/components/UserComponent.astro | 11 +- src/content/LICENSE | 1 - src/content/config.ts | 413 +++++++++++------- src/content/games/crossing-over.md | 1 + src/content/stories/playing-it-safe.md | 1 - src/content/stories/tiny-accident.md | 1 + .../tag-categories/1-types-of-vore.yaml | 2 +- src/content/tag-categories/2-body-types.yaml | 4 +- src/content/tag-categories/3-genders.yaml | 4 +- src/content/tag-categories/5-willingness.yaml | 4 +- .../6-vore-related-scenarios.yaml | 2 +- .../tag-categories/9-type-of-content.yaml | 2 +- src/content/users/anonymous.yaml | 5 +- src/content/users/asof-yeun.yaml | 2 +- src/content/users/bad-manners.yaml | 9 +- src/content/users/hans-woofington.yaml | 4 +- src/content/users/yolkmonkey.yaml | 4 +- src/i18n/index.ts | 209 +++++---- src/layouts/BaseLayout.astro | 7 +- src/layouts/GameLayout.astro | 96 ++-- src/layouts/StoryLayout.astro | 102 +++-- src/pages/api/export-story/[...slug].ts | 115 ++--- src/pages/api/healthcheck.ts | 8 +- src/pages/feed.xml.ts | 3 +- src/pages/games/[...slug].astro | 11 - src/pages/index.astro | 2 +- src/pages/stories/[...slug].astro | 16 +- src/pages/tags.astro | 58 +-- src/pages/tags/[slug].astro | 30 +- src/utils/get_username_for_lang.ts | 13 +- src/utils/is_anonymous_user.ts | 6 +- tsconfig.json | 3 +- 54 files changed, 1005 insertions(+), 962 deletions(-) delete mode 100644 LICENSE create mode 100644 LICENSE.md rename src/pages/licenses.txt.ts => public/licenses.txt (53%) delete mode 100644 scripts/deploy-lftp.ts delete mode 120000 src/content/LICENSE diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 82e5582..966bf65 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,8 @@ { - "recommendations": ["astro-build.astro-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"] + "recommendations": [ + "astro-build.astro-vscode", + "bradlc.vscode-tailwindcss", + "esbenp.prettier-vscode", + "redhat.vscode-yaml" + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index 43f71ef..57dc4ec 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,25 @@ "files.associations": { "*.css": "tailwindcss" }, + "json.schemas": [ + { + "fileMatch": ["/src/content/series/**"], + "url": "./.astro/collections/series.schema.json" + }, + { + "fileMatch": ["/src/content/tag-categories/**"], + "url": "./.astro/collections/tag-categories.schema.json" + }, + { + "fileMatch": ["/src/content/users/**"], + "url": "./.astro/collections/users.schema.json" + } + ], + "yaml.schemas": { + "./.astro/collections/series.schema.json": "/src/content/series/**", + "./.astro/collections/tag-categories.schema.json": "/src/content/tag-categories/**", + "./.astro/collections/users.schema.json": "/src/content/users/**" + }, "[astro]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, diff --git a/LICENSE b/LICENSE deleted file mode 100644 index ea120b0..0000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2024 Bad Manners - -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. diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..1d4c39d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1 @@ +See [public/licenses.txt](public/licenses.txt) diff --git a/README.md b/README.md index 3fd7a82..eec5f18 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Static website built in Astro + Typescript + TailwindCSS. ## Requirements - Node.js 20+ -- (optional) LFTP, for the remote deployment script. +- (optional) rsync, for remote deployment. - (optional) LibreOffice, for the story export script. ## Development @@ -31,7 +31,7 @@ npm run prettier # Prettier formatting Requires `libreoffice` to be installed and in your path. ```bash -npm run export-story -- --output-dir ~/Documents/TO_UPLOAD slug-for-story-to-export +npm run export-story -- -o ~/Documents/TO_UPLOAD slug-for-story-to-export ``` ### Build and deploy to remote @@ -40,19 +40,8 @@ npm run export-story -- --output-dir ~/Documents/TO_UPLOAD slug-for-story-to-exp npm run build ``` -Then, if you're using LFTP: - -1. Create a new `.env` file at the root of the project with your credentials (SSH, SFTP, WebDav, etc.) if you haven't already: - -```env -DEPLOY_LFTP_HOST=https://example-webdav-server.com -DEPLOY_LFTP_USER=example_user -DEPLOY_LFTP_PASSWORD=sup3r_s3cr3t_password -DEPLOY_LFTP_TARGETFOLDER=sites/gallery.badmanners.xyz/ -``` - -2. Run the deploy command: +Then, after configuring the `gallerybm` host (or the name of your choosing) in `~/.ssh/config`: ```bash -npm run deploy-lftp +rsync --delete -acP dist/ gallerybm:/home/public ``` diff --git a/astro.config.mjs b/astro.config.mjs index d5c6758..12c378e 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -19,6 +19,7 @@ export default defineConfig({ build: { assets: "assets", }, + outDir: "./dist", redirects: { "/stories": "/stories/1", }, diff --git a/examples/game.md b/examples/game.md index b45edc0..7d2e131 100644 --- a/examples/game.md +++ b/examples/game.md @@ -17,7 +17,7 @@ tags: [] # series: the-lost-of-the-marshes # relatedStories: [] # relatedGames: [] -# lang: eng +# lang: en --- The game content (i.e. embed) goes here. diff --git a/examples/story.md b/examples/story.md index e7a3bfa..aded0f7 100644 --- a/examples/story.md +++ b/examples/story.md @@ -25,7 +25,7 @@ tags: [] # Some funny summary # relatedStories: [] # relatedGames: [] -# lang: eng +# lang: en --- The story goes here. diff --git a/examples/user.yaml b/examples/user.yaml index ba7eadf..bae2e71 100644 --- a/examples/user.yaml +++ b/examples/user.yaml @@ -1,6 +1,6 @@ name: Nameless User nameLang: - eng: Nameless + en: Nameless tok: jan Nenle pi nimi ala # avatar: /src/assets/images/logo_bm.png links: diff --git a/package-lock.json b/package-lock.json index 52d78bd..96648c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,26 +1,26 @@ { "name": "gallery-badmanners-xyz", - "version": "1.6.0", + "version": "1.6.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gallery-badmanners-xyz", - "version": "1.6.0", + "version": "1.6.1", "dependencies": { - "@astrojs/check": "^0.8.2", + "@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.12.2", + "astro": "^4.13.1", "astro-pagefind": "^1.6.0", "github-slugger": "^2.0.0", - "marked": "^12.0.1", + "marked": "^12.0.2", "pagefind": "^1.1.0", "reading-time": "^1.5.0", "sanitize-html": "^2.13.0", - "tailwindcss": "^3.4.6", + "tailwindcss": "^3.4.7", "tiny-decode": "^0.1.3", "typescript": "^5.5.4" }, @@ -32,7 +32,7 @@ "prettier": "^3.3.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.6.5", - "tsx": "^4.16.2" + "tsx": "^4.16.5" } }, "../astro-pagefind/packages/astro-pagefind": { @@ -83,12 +83,12 @@ } }, "node_modules/@astrojs/check": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.8.2.tgz", - "integrity": "sha512-L0V9dGb2PGvK9Mf3kby99Y+qm7EqxaC9tN1MVCvaqp/3pPPZBadR4XAySHipxXqQsxwJS25WQow8/1kMl1e25g==", + "version": "0.9.2", + "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.2.tgz", + "integrity": "sha512-6rWxtJTbd/ctdAlmla0CAvloGaai5IUTG0K21kctJHHGKJKnGH6Xana7m0zNOtHpVPEJi1SgC/TcsN+ltYt0Cg==", "license": "MIT", "dependencies": { - "@astrojs/language-server": "^2.12.1", + "@astrojs/language-server": "^2.13.2", "chokidar": "^3.5.3", "fast-glob": "^3.3.1", "kleur": "^4.1.5", @@ -102,9 +102,9 @@ } }, "node_modules/@astrojs/compiler": { - "version": "2.9.2", - "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.9.2.tgz", - "integrity": "sha512-Vpu0Ffsj8SoV+N0DFHlxxOMKHwSC9059Xy/OlG1t6uFYSoJXxkBC2WyF6igO7x10V+8uJrhOxaXr3nA90kJXow==", + "version": "2.10.2", + "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.10.2.tgz", + "integrity": "sha512-bvH+v8AirwpRWCkYJEyWYdc5Cs/BjG2ZTxIJzttHilXgfKJAdW2496KsUQKzf5j2tOHtaHXKKn9hb9WZiBGpEg==", "license": "MIT" }, "node_modules/@astrojs/internal-helpers": { @@ -114,12 +114,12 @@ "license": "MIT" }, "node_modules/@astrojs/language-server": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.12.1.tgz", - "integrity": "sha512-CCibE6XwSmrZEKlPDr48LZJN7NWxOurOJK1yOzqZFMNV8Y6DIqF6s1e60gbNNHMZkthWYBNTPno4Ni/XyviinQ==", + "version": "2.13.2", + "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.13.2.tgz", + "integrity": "sha512-l435EZLKjaUO/6iewJ7xqd3eHf3zAosVWG4woILbxluQcianBoNPepnnqAg7uUriZUaC44ae5v0Q+AfB8UI64g==", "license": "MIT", "dependencies": { - "@astrojs/compiler": "^2.9.1", + "@astrojs/compiler": "^2.10.2", "@jridgewell/sourcemap-codec": "^1.4.15", "@volar/kit": "~2.4.0-alpha.15", "@volar/language-core": "~2.4.0-alpha.15", @@ -256,30 +256,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.9.tgz", - "integrity": "sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz", + "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", - "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.9", - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-module-transforms": "^7.24.9", - "@babel/helpers": "^7.24.8", - "@babel/parser": "^7.24.8", - "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.9", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -304,12 +304,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.10", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.10.tgz", - "integrity": "sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", + "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.24.9", + "@babel/types": "^7.25.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -331,12 +331,12 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.8.tgz", - "integrity": "sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.24.8", + "@babel/compat-data": "^7.25.2", "@babel/helper-validator-option": "^7.24.8", "browserslist": "^4.23.1", "lru-cache": "^5.1.1", @@ -355,43 +355,6 @@ "semver": "bin/semver.js" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", - "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", - "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", - "license": "MIT", - "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", - "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", @@ -406,16 +369,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.9.tgz", - "integrity": "sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.24.7", "@babel/helper-module-imports": "^7.24.7", "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7" + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" }, "engines": { "node": ">=6.9.0" @@ -446,18 +408,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.24.8", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", @@ -486,13 +436,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.8.tgz", - "integrity": "sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", + "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.8" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -514,10 +464,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.8.tgz", - "integrity": "sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", + "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.2" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -541,16 +494,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.24.7.tgz", - "integrity": "sha512-+Dj06GDZEFRYvclU6k4bme55GKBEWUmByM/eoKuqg4zTNQHiApWRhQph5fxQB2wAEFvRzL1tOEj1RJ19wJrhoA==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.2.tgz", + "integrity": "sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==", "license": "MIT", "dependencies": { "@babel/helper-annotate-as-pure": "^7.24.7", "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", "@babel/plugin-syntax-jsx": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.2" }, "engines": { "node": ">=6.9.0" @@ -560,33 +513,30 @@ } }, "node_modules/@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.8.tgz", - "integrity": "sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz", + "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.8", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.8", - "@babel/types": "^7.24.8", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.2", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -595,9 +545,9 @@ } }, "node_modules/@babel/types": { - "version": "7.24.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.9.tgz", - "integrity": "sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.24.8", @@ -1582,9 +1532,10 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.25", @@ -1863,9 +1814,9 @@ ] }, "node_modules/@shikijs/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.11.1.tgz", - "integrity": "sha512-Qsn8h15SWgv5TDRoDmiHNzdQO2BxDe86Yq6vIHf5T0cCvmfmccJKIzHtep8bQO9HMBZYCtCBzaXdd1MnxZBPSg==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.12.1.tgz", + "integrity": "sha512-biCz/mnkMktImI6hMfMX3H9kOeqsInxWEyCHbSlL8C/2TR1FqfmGxTLRNwYCKsyCyxWLbB8rEqXRVZuyxuLFmA==", "license": "MIT", "dependencies": { "@types/hast": "^3.0.4" @@ -2285,34 +2236,33 @@ } }, "node_modules/astro": { - "version": "4.12.2", - "resolved": "https://registry.npmjs.org/astro/-/astro-4.12.2.tgz", - "integrity": "sha512-l6OmqlL+FiuSi9x6F+EGZitteOznq1JffOil7st7cdqeMCTEIym4oagI1a6zp6QekliKWEEZWdplGhgh1k1f7Q==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/astro/-/astro-4.13.1.tgz", + "integrity": "sha512-VnMjAc+ykFsIVjgbu9Mt/EA1fMIcPMXbU89h3ATwGOzSIKDsQH72bDgfJkWiwk6u0OE90GeP5EPhAT28Twf9oA==", "license": "MIT", "dependencies": { - "@astrojs/compiler": "^2.9.0", + "@astrojs/compiler": "^2.10.0", "@astrojs/internal-helpers": "0.4.1", "@astrojs/markdown-remark": "5.2.0", "@astrojs/telemetry": "3.1.0", - "@babel/core": "^7.24.9", - "@babel/generator": "^7.24.10", - "@babel/parser": "^7.24.8", - "@babel/plugin-transform-react-jsx": "^7.24.7", - "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.9", + "@babel/core": "^7.25.2", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/plugin-transform-react-jsx": "^7.25.2", + "@babel/traverse": "^7.25.3", + "@babel/types": "^7.25.2", "@types/babel__core": "^7.20.5", "@types/cookie": "^0.6.0", "acorn": "^8.12.1", "aria-query": "^5.3.0", "axobject-query": "^4.1.0", "boxen": "7.1.1", - "chokidar": "^3.6.0", "ci-info": "^4.0.0", "clsx": "^2.1.1", "common-ancestor-path": "^1.0.1", "cookie": "^0.6.0", "cssesc": "^3.0.0", - "debug": "^4.3.5", + "debug": "^4.3.6", "deterministic-object-hash": "^2.0.2", "devalue": "^5.0.0", "diff": "^5.2.0", @@ -2330,7 +2280,7 @@ "http-cache-semantics": "^4.1.1", "js-yaml": "^4.1.0", "kleur": "^4.1.5", - "magic-string": "^0.30.10", + "magic-string": "^0.30.11", "mrmime": "^2.0.0", "ora": "^8.0.1", "p-limit": "^6.1.0", @@ -2339,19 +2289,19 @@ "preferred-pm": "^4.0.0", "prompts": "^2.4.2", "rehype": "^13.0.1", - "semver": "^7.6.2", - "shiki": "^1.10.3", + "semver": "^7.6.3", + "shiki": "^1.12.0", "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "tsconfck": "^3.1.1", "unist-util-visit": "^5.0.0", "vfile": "^6.0.2", - "vite": "^5.3.4", + "vite": "^5.3.5", "vitefu": "^0.2.5", "which-pm": "^3.0.0", "yargs-parser": "^21.1.1", "zod": "^3.23.8", - "zod-to-json-schema": "^3.23.1" + "zod-to-json-schema": "^3.23.2" }, "bin": { "astro": "astro.js" @@ -2958,9 +2908,9 @@ } }, "node_modules/debug": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", - "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", + "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==", "license": "MIT", "dependencies": { "ms": "2.1.2" @@ -4270,12 +4220,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.10", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", - "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "version": "0.30.11", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" + "@jridgewell/sourcemap-codec": "^1.5.0" } }, "node_modules/make-error": { @@ -4295,9 +4245,10 @@ } }, "node_modules/marked": { - "version": "12.0.1", - "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.1.tgz", - "integrity": "sha512-Y1/V2yafOcOdWQCX0XpAKXzDakPOpn6U0YLxTJs3cww6VxOzZV1BTOOYWLvH3gX38cq+iLwljHHTnMtlDfg01Q==", + "version": "12.0.2", + "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz", + "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==", + "license": "MIT", "bin": { "marked": "bin/marked.js" }, @@ -6336,12 +6287,12 @@ } }, "node_modules/shiki": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.11.1.tgz", - "integrity": "sha512-VHD3Q0EBXaaa245jqayBe5zQyMQUdXBFjmGr9MpDaDpAKRMYn7Ff00DM5MLk26UyKjnml3yQ0O2HNX7PtYVNFQ==", + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.12.1.tgz", + "integrity": "sha512-nwmjbHKnOYYAe1aaQyEBHvQymJgfm86ZSS7fT8OaPRr4sbAcBNz7PbfAikMEFSDQ6se2j2zobkXvVKcBOm0ysg==", "license": "MIT", "dependencies": { - "@shikijs/core": "1.11.1", + "@shikijs/core": "1.12.1", "@types/hast": "^3.0.4" } }, @@ -6616,9 +6567,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.6", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", - "integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==", + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz", + "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==", "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -6838,9 +6789,9 @@ "optional": true }, "node_modules/tsx": { - "version": "4.16.2", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.2.tgz", - "integrity": "sha512-C1uWweJDgdtX2x600HjaFaucXTilT7tgUZHbOE4+ypskZ1OP8CRCSDkCxG6Vya9EwaFIVagWwpaVAn5wzypaqQ==", + "version": "4.16.5", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.5.tgz", + "integrity": "sha512-ArsiAQHEW2iGaqZ8fTA1nX0a+lN5mNTyuGRRO6OW3H/Yno1y9/t1f9YOI1Cfoqz63VAthn++ZYcbDP7jPflc+A==", "dev": true, "license": "MIT", "dependencies": { @@ -7122,9 +7073,9 @@ } }, "node_modules/vite": { - "version": "5.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", - "integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", + "version": "5.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz", + "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==", "license": "MIT", "dependencies": { "esbuild": "^0.21.3", @@ -7363,9 +7314,9 @@ } }, "node_modules/vscode-languageserver-textdocument": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz", - "integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", "license": "MIT" }, "node_modules/vscode-languageserver-types": { @@ -7716,9 +7667,9 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.23.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.1.tgz", - "integrity": "sha512-oT9INvydob1XV0v1d2IadrR74rLtDInLvDFfAa1CG0Pmg/vxATk7I2gSelfj271mbzeM4Da0uuDQE/Nkj3DWNw==", + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.2.tgz", + "integrity": "sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw==", "license": "ISC", "peerDependencies": { "zod": "^3.23.3" diff --git a/package.json b/package.json index d3dbd83..58ef62a 100644 --- a/package.json +++ b/package.json @@ -1,33 +1,32 @@ { "name": "gallery-badmanners-xyz", "type": "module", - "version": "1.6.0", + "version": "1.6.1", "scripts": { "dev": "astro dev", "start": "astro dev", - "build": "npm run check && astro build", + "build": "astro check && astro build", "preview": "astro preview", "sync": "astro sync", - "check": "astro check --minimumSeverity warning", + "check": "astro check", "astro": "astro", "prettier": "prettier --write .", - "deploy-lftp": "dotenv tsx scripts/deploy-lftp.ts --", "export-story": "tsx scripts/export-story.ts" }, "dependencies": { - "@astrojs/check": "^0.8.2", + "@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.12.2", + "astro": "^4.13.1", "astro-pagefind": "^1.6.0", "github-slugger": "^2.0.0", - "marked": "^12.0.1", + "marked": "^12.0.2", "pagefind": "^1.1.0", "reading-time": "^1.5.0", "sanitize-html": "^2.13.0", - "tailwindcss": "^3.4.6", + "tailwindcss": "^3.4.7", "tiny-decode": "^0.1.3", "typescript": "^5.5.4" }, @@ -39,6 +38,6 @@ "prettier": "^3.3.3", "prettier-plugin-astro": "^0.14.1", "prettier-plugin-tailwindcss": "^0.6.5", - "tsx": "^4.16.2" + "tsx": "^4.16.5" } } diff --git a/src/pages/licenses.txt.ts b/public/licenses.txt similarity index 53% rename from src/pages/licenses.txt.ts rename to public/licenses.txt index 10010f1..3bf892e 100644 --- a/src/pages/licenses.txt.ts +++ b/public/licenses.txt @@ -1,19 +1,11 @@ -import type { APIRoute } from "astro"; +The source code of this website is licensed under the MIT License: https://opensource.org/license/mit + +The stories and games hosted on the website are copyrighted by me and licensed under CC-BY-NC-ND-4.0: https://creativecommons.org/licenses/by-nc-nd/4.0/ -const licenses = ` The briefcase logo and any unattributed characters are copyrighted and trademarked by me. -The source code of this website is licensed under the MIT License. The content hosted on it (i.e. stories, games, and respective thumbnails/images) is copyrighted by me and distributed under the CC BY-NC-ND 4.0 license. +The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1: https://opensource.org/license/ofl-1-1 -The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1. - -The generic SVG icons were created by Font Awesome and are distributed under the CC-BY-4.0 license. +The generic SVG icons were created by Font Awesome and are distributed under CC-BY-4.0: https://creativecommons.org/licenses/by/4.0/ All third-party trademarks and attributed characters belong to their respective owners, and I'm not affiliated with any of them. -`.trim(); - -const headers = { "Content-Type": "text/plain; charset=utf-8" }; - -export const GET: APIRoute = () => { - return new Response(licenses, { headers }); -}; diff --git a/scripts/deploy-lftp.ts b/scripts/deploy-lftp.ts deleted file mode 100644 index d418de3..0000000 --- a/scripts/deploy-lftp.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { spawn } from "node:child_process"; -import { program, Option } from "commander"; - -interface DeployLftpOptions { - host: string; - user: string; - password: string; - targetFolder: string; - sourceFolder: string; -} - -async function deployLftp({ host, user, password, targetFolder, sourceFolder }: DeployLftpOptions) { - const process = spawn( - "lftp", - [ - "-c", - [ - `open -u ${user},${password} ${host}`, - `mirror --reverse --include-glob assets/* --delete --only-missing --no-perms --verbose ${sourceFolder} ${targetFolder}`, - `mirror --reverse --exclude-glob assets/* --delete --no-perms --verbose ${sourceFolder} ${targetFolder}`, - `bye`, - ].join("\n"), - ], - { - stdio: "inherit", - }, - ); - await new Promise<void>((resolve, reject) => { - process.on("close", (code) => { - if (code === 0) { - resolve(); - } else { - reject(`deploy-lftp failed with code ${code}`); - } - }); - }); -} - -await program - .name("deploy-lftp") - .description("Deploy files to remote server with LFTP") - .addOption( - new Option("-h, --host <hostname>", "Hostname of the LFTP (i.e. WebDav, SCP, SFTP...) remote.").env( - "DEPLOY_LFTP_HOST", - ), - ) - .addOption(new Option("-u, --user <username>", "Username portion of the LFTP credentials").env("DEPLOY_LFTP_USER")) - .addOption( - new Option("-p, --password <pass>", "Password portion of the LFTP credentials").env("DEPLOY_LFTP_PASSWORD"), - ) - .addOption( - new Option("-t, --target-folder <remoteDir>", "Folder to mirror files to in the LFTP remote").env( - "DEPLOY_LFTP_TARGETFOLDER", - ), - ) - .addOption( - new Option("-s, --source-folder <localDir>", "Folder to read files from in the local machine") - .env("DEPLOY_LFTP_SOURCEFOLDER") - .default("dist/"), - ) - .action(deployLftp) - .parseAsync(); diff --git a/scripts/export-story.ts b/scripts/export-story.ts index 8108a0a..178ce38 100644 --- a/scripts/export-story.ts +++ b/scripts/export-story.ts @@ -1,4 +1,4 @@ -import { type ChildProcess, exec, execSync } from "node:child_process"; +import { type ChildProcess, exec, spawnSync } from "node:child_process"; import { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join as pathJoin, normalize } from "node:path"; @@ -150,7 +150,13 @@ async function exportStory(slug: string, options: { outputDir: string }) { await writeFile(pathJoin(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*")); const tempDir = await mkdtemp(pathJoin(tmpdir(), "export-story-")); await writeFile(pathJoin(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n")); - execSync(`libreoffice --convert-to "rtf:Rich Text Format" --outdir ${tempDir} ${pathJoin(tempDir, "temp.txt")}`); + spawnSync("libreoffice", [ + "--convert-to", + "rtf:Rich Text Format", + "--outdir", + tempDir, + pathJoin(tempDir, "temp.txt"), + ]); const rtfText = await readFile(pathJoin(tempDir, "temp.rtf"), "utf-8"); const rtfStyles = getRTFStyles(rtfText); await writeFile( @@ -158,7 +164,6 @@ async function exportStory(slug: string, options: { outputDir: string }) { rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]), ); console.log("Success!"); - process.exit(0); } await program diff --git a/src/assets/thumbnails/bm_20_playing_it_safe.png b/src/assets/thumbnails/bm_20_playing_it_safe.png index 1dc1384e8477b07043ab641354b846fc0e41b71b..3f075b127fcb09af7f386c90916323594c5e6882 100755 GIT binary patch literal 20821 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 literal 22894 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 diff --git a/src/components/AgeRestrictedModal.astro b/src/components/AgeRestrictedModal.astro index 764f2c1..7edb7af 100644 --- a/src/components/AgeRestrictedModal.astro +++ b/src/components/AgeRestrictedModal.astro @@ -54,11 +54,13 @@ (function () { if (localStorage.getItem("ageVerified") !== "true") { document.body.appendChild( - (document.getElementById("template-modal-age-restricted") as HTMLTemplateElement).content.cloneNode(true), + document + .querySelector<HTMLElementTagNameMap["template"]>("template#template-modal-age-restricted")! + .content.cloneNode(true), ); - const modal = document.querySelector<HTMLDivElement>("body > #modal-age-restricted")!; - const rejectButton = modal.querySelector<HTMLButtonElement>("button[data-modal-reject]")!; - const acceptButton = modal.querySelector<HTMLButtonElement>("button[data-modal-accept]")!; + const modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted")!; + const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!; + const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!; function onRejectButtonClick(e: MouseEvent) { e.preventDefault(); location.href = "about:blank"; diff --git a/src/components/Authors.astro b/src/components/Authors.astro index 7e74761..89a156c 100644 --- a/src/components/Authors.astro +++ b/src/components/Authors.astro @@ -1,5 +1,5 @@ --- -import { type Lang } from "../content/config"; +import type { Lang } from "../content/config"; import { t } from "../i18n"; type Props = { @@ -12,4 +12,12 @@ const authors = Astro.slots.has("default") : []; --- -{authors.length ? <p id="authors" set:html={t(lang, "story/authors", authors)} /> : null} +{ + authors.length ? ( + <p + id="authors" + aria-label={t(lang, "story/authors_aria_label", authors)} + set:html={t(lang, "story/authors", authors)} + /> + ) : null +} diff --git a/src/components/Commissioners.astro b/src/components/Commissioners.astro index bd4400e..cb3f5f7 100644 --- a/src/components/Commissioners.astro +++ b/src/components/Commissioners.astro @@ -1,5 +1,5 @@ --- -import { type Lang } from "../content/config"; +import type { Lang } from "../content/config"; import { t } from "../i18n"; type Props = { @@ -12,4 +12,12 @@ const commissioners = Astro.slots.has("default") : []; --- -{commissioners.length ? <p id="commissioners" set:html={t(lang, "story/commissioned_by", commissioners)} /> : null} +{ + commissioners.length ? ( + <p + id="commissioners" + aria-label={t(lang, "story/commissioners_aria_label", commissioners)} + set:html={t(lang, "story/commissioned_by", commissioners)} + /> + ) : null +} diff --git a/src/components/CopyrightedCharacters.astro b/src/components/CopyrightedCharacters.astro index e4f2936..94a5ff5 100644 --- a/src/components/CopyrightedCharacters.astro +++ b/src/components/CopyrightedCharacters.astro @@ -1,6 +1,6 @@ --- -import { type CollectionEntry } from "astro:content"; -import { type Lang } from "../content/config"; +import type { CollectionEntry } from "astro:content"; +import type { Lang } from "../content/config"; import { t } from "../i18n"; import UserComponent from "./UserComponent.astro"; import CopyrightedCharactersItem from "./CopyrightedCharactersItem.astro"; @@ -15,7 +15,7 @@ const { copyrightedCharacters, lang } = Astro.props; { copyrightedCharacters ? ( - <section id="copyrighted-characters"> + <section id="copyrighted-characters" aria-label={t(lang, "characters/copyrighted_characters_aria_label")}> <ul> {copyrightedCharacters.map(([owner, characterList]) => ( <CopyrightedCharactersItem diff --git a/src/components/DarkModeScript.astro b/src/components/DarkModeScript.astro index 3abd902..1bf3f22 100644 --- a/src/components/DarkModeScript.astro +++ b/src/components/DarkModeScript.astro @@ -6,12 +6,12 @@ <script> (function () { - var colorScheme = localStorage.getItem("colorScheme"); + let colorScheme = localStorage.getItem("colorScheme"); if (colorScheme == null || colorScheme === "auto") { colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } - document.querySelectorAll("button[data-dark-mode]").forEach(function (button) { - button.addEventListener("click", function (e) { + document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => { + button.addEventListener("click", (e) => { e.preventDefault(); if (colorScheme === "dark") { colorScheme = "light"; diff --git a/src/components/MastodonComments.astro b/src/components/MastodonComments.astro index a9eff55..19fb3c2 100644 --- a/src/components/MastodonComments.astro +++ b/src/components/MastodonComments.astro @@ -1,37 +1,47 @@ --- +import type { Lang } from "../content/config"; + type Props = { - instance?: string; - user?: string; - postId?: string; + lang: Lang; + link: string; + instance: string; + user: string; + postId: string; }; -const { instance, user, postId } = Astro.props; +const { link, instance, user, postId } = Astro.props; --- <section id="comment-section" - class="hidden px-2 font-serif" + class="px-2 font-serif" aria-describedby="title-comment-section" - data-instance={instance || ""} - data-user={user || ""} - data-post-id={postId || ""} + data-link={link} + data-instance={instance} + data-user={user} + data-post-id={postId} > <h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> Comments </h2> <div class="text-stone-800 dark:text-stone-100" id="comments"> - <button - class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900" - id="load-comments-button" - data-load-comments - > - <span>Click to load comments</span> - </button> + <p class="my-1"> + <a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a> + </p> </div> </section> -<template id="template-comments-loading"> - <svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24"> +<template id="template-button"> + <button + class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900" + id="load-comments-button" + > + <span>Click to load comments</span> + </button> +</template> + +<template id="template-button-loading"> + <svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <path class="opacity-100" @@ -45,12 +55,16 @@ const { instance, user, postId } = Astro.props; <template id="template-comment-box"> <div class="my-2 rounded-md border-2 border-stone-400 bg-stone-200 p-2 dark:border-stone-600 dark:bg-stone-800"> <div class="ml-1"> - <a data-author class="text-link flex items-center text-lg hover:underline focus:underline"> + <a data-author class="text-link flex items-center text-lg hover:underline focus:underline" target="_blank"> <img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" /> <span data-display-name></span> </a> - <a data-post-link class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"> - <span class="mr-1" data-publish-date></span> + <a + data-post-link + class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline" + target="_blank" + > + <span class="mr-1" data-publish-date aria-label="Publish date"></span> </a> </div> <div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div> @@ -72,11 +86,18 @@ const { instance, user, postId } = Astro.props; </svg> </div> </div> - <div data-comment-thread class="-mb-2"></div> + <div data-comment-thread class="-mb-2" aria-hidden></div> </div> </template> <script> + interface Post { + link: string; + instance: string; + user: string; + postId: string; + } + interface Emoji { shortcode: string; url: string; @@ -104,115 +125,131 @@ const { instance, user, postId } = Astro.props; emojis: Emoji[]; } + interface ApiResponse { + ancestors: Comment[]; + descendants: Comment[]; + } + (function () { - const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) => - emojis.reduce( + function replaceEmojis(text: string, emojis: Emoji[]) { + return emojis.reduce( (acc, emoji) => acc.replaceAll( `:${emoji.shortcode}:`, - `<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`, + `<img class="inline mx-[1px] w-5" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`, ), text, ); + } - const commentSection = document.querySelector<Element>("#comment-section")!; - const instance = commentSection.getAttribute("data-instance"); - const user = commentSection.getAttribute("data-user"); - const postId = commentSection.getAttribute("data-post-id"); - if (instance && user && postId) { - commentSection.classList.remove("hidden"); - commentSection.querySelector<HTMLButtonElement>("button[data-load-comments]")!.addEventListener("click", (e) => { + async function renderComments(section: Element, post: Post) { + const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!; + try { + const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`); + if (!response.ok) { + throw new Error(`Received error status ${response.status} - ${response.statusText}!`); + } + const data: ApiResponse = await response.json(); + + const commentsList: HTMLElement[] = []; + const commentMap: Record<string, number> = {}; + const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>( + "template#template-comment-box", + )!; + data.descendants.forEach((comment) => { + const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement; + + const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!; + commentBoxAuthor.href = comment.account.url; + const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!; + avatar.src = comment.account.avatar; + avatar.alt = `Profile picture of ${comment.account.username}`; + const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!; + displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis); + + const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!; + commentBoxPostLink.href = comment.url; + const publishDate = + commentBoxPostLink.querySelector<HTMLElementTagNameMap["span"]>("span[data-publish-date]")!; + publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + + if (comment.edited_at) { + const edited = document.createElement("span"); + edited.className = "italic"; + edited.innerText = "(edited)"; + commentBoxPostLink.appendChild(edited); + } + + const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!; + commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis); + + const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!; + commentBoxFavorites.innerText = comment.favourites_count.toString(); + + const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!; + commentBoxReblogs.innerText = comment.reblogs_count.toString(); + + if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) { + commentMap[comment.id] = commentsList.length; + commentsList.push(commentBox); + } else { + const commentsIndex = commentMap[comment.in_reply_to_id]; + commentMap[comment.id] = commentsIndex; + const parentThreadDiv = + commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!; + parentThreadDiv.removeAttribute("aria-hidden"); + parentThreadDiv.setAttribute("aria-label", "Replies"); + parentThreadDiv.appendChild(commentBox); + } + }); + if (commentsList.length === 0) { + commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">Be the first to join the conversation on Mastodon</a>.</p>`; + } else { + commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`; + commentsDiv.append(...commentsList); + } + } catch (e) { + commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`; + console.error("Fetch Mastodon comments error", e); + } + } + + function initCommentSection() { + const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section"); + if (!commentSection) { + return; + } + const post = { + link: commentSection.dataset.link, + instance: commentSection.dataset.instance, + user: commentSection.dataset.user, + postId: commentSection.dataset.postId, + }; + if (!post.link || !post.instance || !post.user || !post.postId) { + return; + } + const loadCommentsButton = document + .querySelector<HTMLElementTagNameMap["template"]>("template#template-button")! + .content.cloneNode(true) as HTMLButtonElement; + commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!.replaceChildren(loadCommentsButton); + loadCommentsButton.addEventListener("click", (e) => { e.preventDefault(); - const loadCommentsButton = e.target as HTMLButtonElement; loadCommentsButton.setAttribute("disabled", "true"); loadCommentsButton.replaceChildren( - (document.getElementById("template-comments-loading") as HTMLTemplateElement).content.cloneNode(true), + document + .querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")! + .content.cloneNode(true), ); - const renderComments = async () => { - try { - if (!instance || !user || !postId) { - throw new Error( - `Cannot fetch comments without all fields (instance=${instance}, user=${user}, post-id=${postId})`, - ); - } - const response = await fetch(`https://${instance}/api/v1/statuses/${postId}/context`); - if (!response.ok) { - throw new Error(`Received error status ${response.status} - ${response.statusText}!`); - } - const data: { ancestors: Comment[]; descendants: Comment[] } = await response.json(); - - const commentsList: HTMLElement[] = []; - const commentMap: Record<string, number> = {}; - const commentTemplate = document.getElementById("template-comment-box") as HTMLTemplateElement; - data.descendants.forEach((comment) => { - const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement; - - const commentBoxAuthor = commentBox.querySelector<HTMLAnchorElement>("[data-author]")!; - commentBoxAuthor.href = comment.account.url; - commentBoxAuthor.target = "_blank"; - const avatar = commentBoxAuthor.querySelector<HTMLImageElement>("[data-avatar]")!; - avatar.src = comment.account.avatar; - avatar.alt = `Profile picture of ${comment.account.username}`; - const displayName = commentBoxAuthor.querySelector<HTMLSpanElement>("[data-display-name]")!; - displayName.innerHTML = replaceEmojis( - comment.account.display_name, - comment.account.emojis, - "inline mx-[1px] w-5", - ); - - const commentBoxPostLink = commentBox.querySelector<HTMLAnchorElement>("[data-post-link]")!; - commentBoxPostLink.href = comment.url; - commentBoxPostLink.target = "_blank"; - const publishDate = commentBoxPostLink.querySelector<HTMLSpanElement>("[data-publish-date]")!; - publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - hour: "2-digit", - minute: "2-digit", - }); - - if (comment.edited_at) { - const edited = document.createElement("span"); - edited.className = "italic"; - edited.innerText = "(edited)"; - commentBoxPostLink.appendChild(edited); - } - - const commentBoxContent = commentBox.querySelector<HTMLDivElement>("[data-content]")!; - commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis, "inline mx-[1px] w-5"); - - const commentBoxFavorites = commentBox.querySelector<HTMLSpanElement>("[data-favorites]")!; - commentBoxFavorites.innerText = comment.favourites_count.toString(); - - const commentBoxReblogs = commentBox.querySelector<HTMLSpanElement>("[data-reblogs]")!; - commentBoxReblogs.innerText = comment.reblogs_count.toString(); - - if (comment.in_reply_to_id === postId || !(comment.in_reply_to_id in commentMap)) { - commentMap[comment.id] = commentsList.length; - commentsList.push(commentBox); - } else { - const commentsIndex = commentMap[comment.in_reply_to_id]; - commentMap[comment.id] = commentsIndex; - commentsList[commentsIndex] - .querySelector<HTMLDivElement>("[data-comment-thread]")! - .appendChild(commentBox); - } - }); - const commentsDiv = commentSection.querySelector<HTMLDivElement>("#comments")!; - if (commentsList.length === 0) { - commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">Be the first to join the conversation on Mastodon</a>.</p>`; - } else { - commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">by replying on Mastodon</a>.</p>`; - commentsDiv.append(...commentsList); - } - } catch (e) { - loadCommentsButton.innerHTML = `<span>Unable to load comments.</span>`; - console.error("Fetch Mastodon comments error", e); - } - }; - renderComments(); + renderComments(commentSection, post as Post); }); } + + initCommentSection(); })(); </script> diff --git a/src/components/Requesters.astro b/src/components/Requesters.astro index 4725719..245af9b 100644 --- a/src/components/Requesters.astro +++ b/src/components/Requesters.astro @@ -1,5 +1,5 @@ --- -import { type Lang } from "../content/config"; +import type { Lang } from "../content/config"; import { t } from "../i18n"; type Props = { @@ -12,4 +12,12 @@ const requesters = Astro.slots.has("default") : []; --- -{requesters.length ? <p id="requesters" set:html={t(lang, "story/requested_by", requesters)} /> : null} +{ + requesters.length ? ( + <p + id="requesters" + aria-label={t(lang, "story/requesters_aria_label", requesters)} + set:html={t(lang, "story/requested_by", requesters)} + /> + ) : null +} diff --git a/src/components/UserComponent.astro b/src/components/UserComponent.astro index 18ba37e..cacc42a 100644 --- a/src/components/UserComponent.astro +++ b/src/components/UserComponent.astro @@ -1,6 +1,6 @@ --- -import { type CollectionEntry } from "astro:content"; -import { type Lang } from "../content/config"; +import type { CollectionEntry } from "astro:content"; +import type { Lang } from "../content/config"; import { getUsernameForLang } from "../utils/get_username_for_lang"; type Props = { @@ -12,12 +12,7 @@ let { user, lang } = Astro.props; const username = getUsernameForLang(user, lang); let link: string | null = null; if (user.data.preferredLink) { - const preferredLink = user.data.links[user.data.preferredLink]!; - if (typeof preferredLink === "string") { - link = preferredLink; - } else { - link = preferredLink[0]; - } + link = user.data.links[user.data.preferredLink]!.link; } --- diff --git a/src/content/LICENSE b/src/content/LICENSE deleted file mode 120000 index 0b24ab4..0000000 --- a/src/content/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../assets/LICENSE \ No newline at end of file diff --git a/src/content/config.ts b/src/content/config.ts index 2ff83af..4a8ba48 100644 --- a/src/content/config.ts +++ b/src/content/config.ts @@ -2,89 +2,214 @@ import { defineCollection, reference, z } from "astro:content"; // Constants -export const WEBSITE_LIST = [ - "website", - "eka", - "furaffinity", - "weasyl", - "inkbunny", - "sofurry", - "mastodon", - "twitter", - "bluesky", - "itaku", -] as const; -export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const; -export const DEFAULT_LANG = "eng"; -export const DEFAULT_AUTHOR_ID = "bad-manners"; +export const DEFAULT_LANG = "en"; export const ANONYMOUS_USER_ID = "anonymous"; -// Validators - -const ekaPostUrlRegex = /^(?:https:\/\/)(?:www\.)?aryion\.com\/g4\/view\/([1-9]\d*)\/?$/; -const furaffinityPostUrlRegex = /^(?:https:\/\/)(?:www\.)?furaffinity\.net\/view\/([1-9]\d*)\/?$/; -const weasylPostUrlRegex = - /^(?:https:\/\/)(?:www\.)?weasyl\.com\/~([a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/([1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/; -const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\d*)\/?$/; -const sofurryPostUrlRegex = /^(?:https:\/\/)www\.sofurry\.com\/view\/([1-9]\d*)\/?$/; -const mastodonPostUrlRegex = /^(?:https:\/\/)((?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@([a-zA-Z][a-zA-Z0-9_-]+)\/([1-9]\d*)\/?$/; - -const refineAuthors = [ - (value: { id: any } | any[]) => "id" in value || value.length > 0, - `"authors" cannot be empty`, -] as const; -const refineCopyrightedCharacters = [ - (value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1, - `"copyrightedCharacters" cannot mix empty catch-all key with other keys`, -] as const; - // Transformers -const trimText = (text: string) => text.trim(); -const adjustDateForUTCOffset = (date: Date) => - new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0); -const parseMastodonPostUrl = (url: string, ctx: z.RefinementCtx) => { - const match = mastodonPostUrlRegex.exec(url); - if (!match) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: `"mastodon" post contains an invalid URL`, - }); - return z.NEVER; - } - return { - instance: match[1]!, - user: match[2]!, - postId: match[3]!, +function parseRegex<R extends { [key: string]: string }>(regex: RegExp) { + return (url: string, ctx: z.RefinementCtx) => { + const match = regex.exec(url); + if (!match?.groups) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `"${ctx.path}" did not match regex`, + }); + return z.NEVER; + } + return match.groups as R; }; -}; +} + +// Schema definitions + +/** Record of website links for a user. + * + * For each entry, you can enter a URL for the value or - for any key apart + * from `website` - a pre-parsed object containing the link and username. + */ +const websiteLinks = z.object({ + website: z + .string() + .url() + .transform((link) => { + link; + }), + eka: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>( + /^(?:https?:\/\/)?(?:www\.)?aryion\.com\/g4\/user\/(?<username>[^\/]+)\/?$/, + )(link, ctx); + return { link, username }; + }), + ), + furaffinity: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>( + /^(?:https?:\/\/)?(?:www\.)?furaffinity\.net\/user\/(?<username>[^\/]+)\/?$/, + )(link, ctx); + return { link, username }; + }), + ), + weasyl: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>( + /^(?:https?:\/\/)?(?:www\.)?weasyl\.com\/\~(?<username>[^\/]+)\/?$/, + )(link, ctx); + return { link, username }; + }), + ), + inkbunny: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>( + /^(?:https?:\/\/)?(?:www\.)?inkbunny\.net\/(?<username>[^\/]+)\/?$/, + )(link, ctx); + return { link, username }; + }), + ), + sofurry: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>(/^(?:https?:\/\/)?(?<username>[^\.]+).sofurry.com\/?$/)( + link, + ctx, + ); + return { link, username }; + }), + ), + mastodon: z + .object({ link: z.string().url(), username: z.string().regex(/^[^@]+@[^@]+$/) }) + .or( + z.string().transform((link, ctx) => { + const { username, instance } = parseRegex<{ username: string; instance: string }>( + /^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/(?:@|users\/)(?<username>[a-zA-Z][a-zA-Z0-9_-]+)\/?$/, + )(link, ctx); + return { link, username: `${username}@${instance}` }; + }), + ) + .transform(({ link, username }) => { + const i = username.indexOf("@"); + return { link, username, handle: username.slice(0, i), instance: username.slice(i + 1) }; + }), + twitter: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>( + /^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/@?(?<username>[^\/]+)\/?$/, + )(link, ctx); + return { link, username }; + }), + ), + bluesky: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>( + /^(?:https?:\/\/)?bsky\.app\/profile\/(?<username>[^\/]+)\/?$/, + )(link, ctx); + return { link, username }; + }), + ), + itaku: z.object({ link: z.string().url(), username: z.string() }).or( + z.string().transform((link, ctx) => { + const { username } = parseRegex<{ username: string }>( + /^(?:https?:\/\/)?(?:www\.)?itaku\.ee\/profile\/(?<username>[^\/]+)\/?$/, + )(link, ctx); + return { link, username }; + }), + ), +}); +/** Available languages. See https://r12a.github.io/app-subtags/ */ +const lang = z.enum(["en", "tok"]).default(DEFAULT_LANG); +/** Platforms for a game. */ +const platform = z.enum(["web", "windows", "linux", "macos", "android", "ios"]); +const userList = z + .array(reference("users")) + .refine((value) => value.length > 0, `user list cannot be empty`) + .or(reference("users").transform((user) => [user])); +/** A record of the format `{"Character name": "user-id"}`. + * + * An empty character name `""` indicates that all characters are copyrighted + * by a certain user. + */ +const copyrightedCharacters = z + .record(z.string(), reference("users")) + .refine( + (value) => !("" in value) || Object.keys(value).length == 1, + `"copyrightedCharacters" cannot mix empty catch-all key with other keys`, + ) + .default({}); +/** A record of the format `{ en: string, tok?: string, ... }`. */ +const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string())); +/** Common attributes for published content (stories + games). */ +const publishedContent = z.object({ + // Required parameters + title: z.string(), + authors: userList, + contentWarning: z.string().trim(), + // Required parameters, but optional for drafts (isDraft == true) + pubDate: z + .date() + .transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0)) + .optional(), + description: z.string().trim(), + tags: z.array(z.string()), + // Optional parameters + isDraft: z.boolean().default(false), + relatedStories: z.array(reference("stories")).default([]), + relatedGames: z.array(reference("games")).default([]), + lang: lang, + copyrightedCharacters: copyrightedCharacters, + series: reference("series").optional(), + posts: z + .object({ + eka: z.string().transform((link, ctx) => { + const { postId } = parseRegex<{ postId: string }>( + /^(?:https?:\/\/)(?:www\.)?aryion\.com\/g4\/view\/(?<postId>[1-9]\d*)\/?$/, + )(link, ctx); + return { link, postId }; + }), + furaffinity: z.string().transform((link, ctx) => { + const { postId } = parseRegex<{ postId: string }>( + /^(?:https?:\/\/)(?:www\.)?furaffinity\.net\/view\/(?<postId>[1-9]\d*)\/?$/, + )(link, ctx); + return { link, postId }; + }), + weasyl: z.string().transform((link, ctx) => { + const { user, postId } = parseRegex<{ user: string; postId: string }>( + /^(?:https?:\/\/)(?:www\.)?weasyl\.com\/~(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/(?<postId>[1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/, + )(link, ctx); + return { link, user, postId }; + }), + inkbunny: z.string().transform((link, ctx) => { + const { postId } = parseRegex<{ postId: string }>( + /^(?:https?:\/\/)(?:www\.)?inkbunny\.net\/s\/(?<postId>[1-9]\d*)\/?$/, + )(link, ctx); + return { link, postId }; + }), + sofurry: z.string().transform((link, ctx) => { + const { postId } = parseRegex<{ postId: string }>( + /^(?:https?:\/\/)www\.sofurry\.com\/view\/(?<postId>[1-9]\d*)\/?$/, + )(link, ctx); + return { link, postId }; + }), + bluesky: z.string().transform((link, ctx) => { + const { user, postId } = parseRegex<{ user: string; postId: string }>( + /^(?:https?:\/\/)bsky\.app\/profile\/(?<user>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/post\/(?<postId>[a-z0-9]+)\/?$/, + )(link, ctx); + return { link, user, postId }; + }), + mastodon: z.string().transform((link, ctx) => { + const { instance, user, postId } = parseRegex<{ instance: string; user: string; postId: string }>( + /^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/(?<postId>[1-9]\d*)\/?$/, + )(link, ctx); + return { link, instance, user, postId }; + }), + }) + .partial() + .default({}), +}); // Types -const lang = z.enum(["eng", "tok"]).default(DEFAULT_LANG); -const website = z.enum(WEBSITE_LIST); -const platform = z.enum(GAME_PLATFORMS); -const mastodonPost = z - .object({ - instance: z.string(), - user: z.string(), - postId: z.string(), - }) - .or(z.string().transform(parseMastodonPostUrl)); -const userList = z - .array(reference("users")) - .or(reference("users").transform((user) => [user])) - .refine((value) => value.length > 0, `user list cannot be empty`); -const authors = userList.default([DEFAULT_AUTHOR_ID]); -const copyrightedCharacters = z - .record(z.string(), reference("users")) - .default({}) - .refine(...refineCopyrightedCharacters); -// { eng: string, tok?: string, ... } -const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string())); - export type Lang = z.output<typeof lang>; -export type Website = z.infer<typeof website>; +export type Website = keyof z.input<typeof websiteLinks>; export type GamePlatform = z.infer<typeof platform>; export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>; @@ -93,79 +218,52 @@ export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>; const storiesCollection = defineCollection({ type: "content", schema: ({ image }) => - z.object({ - // Required - title: z.string(), - wordCount: z.number().int().optional(), - contentWarning: z.string().transform(trimText), - description: z.string().transform(trimText), - tags: z.array(z.string()), - // Optional - pubDate: z.date().transform(adjustDateForUTCOffset).optional(), - isDraft: z.boolean().default(false), - shortTitle: z.string().optional(), - authors, - summary: z.string().transform(trimText).optional(), - thumbnail: image().optional(), - thumbnailWidth: z.number().int().optional(), - thumbnailHeight: z.number().int().optional(), - series: reference("series").optional(), - commissioner: userList.optional(), - requester: userList.optional(), - copyrightedCharacters: copyrightedCharacters, - lang, - prev: reference("stories").nullish(), - next: reference("stories").nullish(), - relatedStories: z.array(reference("stories")).default([]), - relatedGames: z.array(reference("games")).default([]), - posts: z - .object({ - eka: z.string().regex(ekaPostUrlRegex), - furaffinity: z.string().regex(furaffinityPostUrlRegex), - weasyl: z.string().regex(weasylPostUrlRegex), - inkbunny: z.string().regex(inkbunnyPostUrlRegex), - sofurry: z.string().regex(sofurryPostUrlRegex), - mastodon: mastodonPost, - }) - .partial() - .default({}), - }), + z + .object({ + // Required parameters, but optional for drafts (isDraft == true) + wordCount: z.number().int().optional(), + thumbnail: image().optional(), + // Optional parameters + shortTitle: z.string().optional(), + commissioner: userList.optional(), + requester: userList.optional(), + summary: z.string().trim().optional(), + thumbnailWidth: z.number().int().optional(), + thumbnailHeight: z.number().int().optional(), + prev: reference("stories").nullish(), + next: reference("stories").nullish(), + }) + .and(publishedContent) + .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published story`) + .refine( + ({ isDraft, contentWarning }) => isDraft || contentWarning, + `Missing "contentWarning" for published story`, + ) + .refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`) + .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published story`) + .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`) + .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published story`), }); const gamesCollection = defineCollection({ type: "content", schema: ({ image }) => - z.object({ - // Required - title: z.string(), - contentWarning: z.string().transform(trimText), - description: z.string().transform(trimText), - platforms: z.array(platform).refine((platforms) => platforms.length > 0, `"platforms" cannot be empty`), - tags: z.array(z.string()), - // Optional - pubDate: z.date().transform(adjustDateForUTCOffset).optional(), - isDraft: z.boolean().default(false), - authors, - thumbnail: image().optional(), - thumbnailWidth: z.number().int().optional(), - thumbnailHeight: z.number().int().optional(), - series: reference("series").optional(), - copyrightedCharacters: copyrightedCharacters, - lang, - relatedStories: z.array(reference("stories")).default([]), - relatedGames: z.array(reference("games")).default([]), - posts: z - .object({ - eka: z.string().regex(ekaPostUrlRegex), - furaffinity: z.string().regex(furaffinityPostUrlRegex), - weasyl: z.string().regex(weasylPostUrlRegex), - inkbunny: z.string().regex(inkbunnyPostUrlRegex), - sofurry: z.string().regex(sofurryPostUrlRegex), - mastodon: mastodonPost, - }) - .partial() - .default({}), - }), + z + .object({ + // Required parameters, but optional for drafts (isDraft == true) + platforms: z.array(platform).default([]), + thumbnail: image().optional(), + // Optional parameters + thumbnailWidth: z.number().int().optional(), + thumbnailHeight: z.number().int().optional(), + }) + .and(publishedContent) + .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published game`) + .refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`) + .refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`) + .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published game`) + .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`) + .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published game`), }); // Data collections @@ -175,12 +273,11 @@ const usersCollection = defineCollection({ schema: ({ image }) => z .object({ - // Required - name: z.string(), - links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])), - // Optional - preferredLink: website.nullish(), - lang: langRecord.optional(), + // Required parameters + name: langRecord.or(z.string()), + links: websiteLinks.partial(), + // Optional parameters + preferredLink: websiteLinks.keyof().nullish(), avatar: image().optional(), }) .refine( @@ -195,7 +292,7 @@ const usersCollection = defineCollection({ const seriesCollection = defineCollection({ type: "data", schema: z.object({ - // Required + // Required parameters name: z.string(), url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`), }), @@ -204,16 +301,18 @@ const seriesCollection = defineCollection({ const tagCategoriesCollection = defineCollection({ type: "data", schema: z.object({ - // Required + // Required parameters name: z.string(), index: z.number().int(), - tags: z.array( - z.object({ - name: z.union([z.string(), langRecord]), - description: z.string().optional(), - related: z.array(z.string()).optional(), - }), - ), + tags: z + .array( + z.object({ + name: langRecord.or(z.string()), + description: z.string().trim().optional(), + related: z.array(z.string()).default([]), + }), + ) + .refine((tags) => tags.length, `"tags" cannot be empty`), }), }); diff --git a/src/content/games/crossing-over.md b/src/content/games/crossing-over.md index 8135d42..8c5da85 100644 --- a/src/content/games/crossing-over.md +++ b/src/content/games/crossing-over.md @@ -28,6 +28,7 @@ posts: inkbunny: https://inkbunny.net/s/3262911 sofurry: https://www.sofurry.com/view/2109688 weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game + bluesky: https://bsky.app/profile/badmanners.xyz/post/3kmigrf5q2x24 mastodon: https://meow.social/@BadManners/112009918919441027 tags: - oral vore diff --git a/src/content/stories/playing-it-safe.md b/src/content/stories/playing-it-safe.md index 62005b3..436148f 100644 --- a/src/content/stories/playing-it-safe.md +++ b/src/content/stories/playing-it-safe.md @@ -2,7 +2,6 @@ slug: playing-it-safe title: Playing It Safe pubDate: 2024-08-08 -isDraft: true authors: bad-manners wordCount: 9900 contentWarning: > diff --git a/src/content/stories/tiny-accident.md b/src/content/stories/tiny-accident.md index 787da56..cdc5c74 100644 --- a/src/content/stories/tiny-accident.md +++ b/src/content/stories/tiny-accident.md @@ -16,6 +16,7 @@ posts: inkbunny: https://inkbunny.net/s/3283508 sofurry: https://www.sofurry.com/view/2118138 weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident + bluesky: https://bsky.app/profile/badmanners.xyz/post/3kok52wijz32c mastodon: https://meow.social/@BadManners/112157812554023271 tags: - anthro predator diff --git a/src/content/tag-categories/1-types-of-vore.yaml b/src/content/tag-categories/1-types-of-vore.yaml index 165b47d..ce24be4 100644 --- a/src/content/tag-categories/1-types-of-vore.yaml +++ b/src/content/tag-categories/1-types-of-vore.yaml @@ -1,7 +1,7 @@ name: Types of vore index: 1 tags: - - name: { eng: oral vore, tok: moku musi kepeken uta } + - name: { en: oral vore, tok: moku musi kepeken uta } description: Scenarios where prey are consumed by the predator through their mouth. - name: anal vore description: Scenarios where prey are consumed by the predator through their butt/anus. diff --git a/src/content/tag-categories/2-body-types.yaml b/src/content/tag-categories/2-body-types.yaml index 829e7b8..ddadbdf 100644 --- a/src/content/tag-categories/2-body-types.yaml +++ b/src/content/tag-categories/2-body-types.yaml @@ -7,7 +7,7 @@ tags: description: Scenarios where at least one of the predators is an animal based on a real or mythological creature. - name: taur predator description: Scenarios where at least one of the predators is a multi-legged centaur-like creature, with an animal lower body and anthropomorphic upper body. - - name: { eng: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale } + - name: { en: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale } description: Scenarios where the body type of at least one of the predators is left ambiguous. - name: human prey description: Scenarios where at least one of the prey is a human person. @@ -15,5 +15,5 @@ tags: description: Scenarios where at least one of the prey is an anthropomorphic animal, i.e. generally regarded as a "furry". - name: feral prey description: Scenarios where at least one of the predators is an animal based on a real or mythological creature. - - name: { eng: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale } + - name: { en: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale } description: Scenarios where the body type of at least one of the predators is left ambiguous. diff --git a/src/content/tag-categories/3-genders.yaml b/src/content/tag-categories/3-genders.yaml index ffdaa6a..aff336e 100644 --- a/src/content/tag-categories/3-genders.yaml +++ b/src/content/tag-categories/3-genders.yaml @@ -13,7 +13,7 @@ tags: description: Scenarios where at least one of the predators is a woman and/or female-presenting. - name: non-binary predator description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not. - - name: { eng: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije } + - name: { en: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije } description: Scenarios where the gender at least one of the predators is left ambiguous. - name: male prey description: Scenarios where at least one of the prey is a man and/or male-presenting. @@ -27,5 +27,5 @@ tags: - female prey - name: non-binary prey description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not. - - name: { eng: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije } + - name: { en: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije } description: Scenarios where the gender at least one of the predators is left ambiguous. diff --git a/src/content/tag-categories/5-willingness.yaml b/src/content/tag-categories/5-willingness.yaml index c3855cb..326cd50 100644 --- a/src/content/tag-categories/5-willingness.yaml +++ b/src/content/tag-categories/5-willingness.yaml @@ -1,7 +1,7 @@ name: Willingness index: 5 tags: - - name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi } + - name: { en: willing predator, tok: jan pi wawa mute li wile e moku musi } description: Scenarios where at least one of the predators participates in vore willingly. - name: semi-willing predator description: Scenarios where the willingness of at least one of the predators might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous. @@ -13,7 +13,7 @@ tags: description: Scenarios where at least one of the prey participates in vore willingly. - name: semi-willing prey description: Scenarios where the willingness of at least one of the prey might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous. - - name: { eng: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi } + - name: { en: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi } description: Scenarios where at least one of the prey participates in vore unwillingly. - name: asleep prey description: Scenarios where at least one of the predators participates in vore while asleep. diff --git a/src/content/tag-categories/6-vore-related-scenarios.yaml b/src/content/tag-categories/6-vore-related-scenarios.yaml index 5af474e..793ce9b 100644 --- a/src/content/tag-categories/6-vore-related-scenarios.yaml +++ b/src/content/tag-categories/6-vore-related-scenarios.yaml @@ -66,4 +66,4 @@ tags: - name: soul vore description: Scenarios where predators consume a soul instead of their prey's body. - name: Vore Day - description: Stories created in commemoration of Vore Day, which is celebrated on August 8ᵗʰ, and/or are set in said day. + description: Stories created in commemoration of Vore Day, which is celebrated on August 8th, and/or are set in said day. diff --git a/src/content/tag-categories/9-type-of-content.yaml b/src/content/tag-categories/9-type-of-content.yaml index b1a76f6..1c12c00 100644 --- a/src/content/tag-categories/9-type-of-content.yaml +++ b/src/content/tag-categories/9-type-of-content.yaml @@ -5,7 +5,7 @@ tags: description: Stories made by someone else's request, as a gift. - name: commission description: Stories made as part of a commission to someone else. - - name: { eng: flash fiction, tok: lipu lili } + - name: { en: flash fiction, tok: lipu lili } description: Short-format stories of no more than 2,500 words. - name: toki pona description: Stories written in toki pona, the language of good. diff --git a/src/content/users/anonymous.yaml b/src/content/users/anonymous.yaml index d1c1ea4..3d0ff6f 100644 --- a/src/content/users/anonymous.yaml +++ b/src/content/users/anonymous.yaml @@ -1,6 +1,5 @@ -name: Anonymous -lang: - eng: anonymous +name: + en: anonymous tok: jan pi nimi ala links: {} preferredLink: ~ diff --git a/src/content/users/asof-yeun.yaml b/src/content/users/asof-yeun.yaml index 51f891d..36d7e8c 100644 --- a/src/content/users/asof-yeun.yaml +++ b/src/content/users/asof-yeun.yaml @@ -1,7 +1,7 @@ name: Asof Yeun links: eka: https://aryion.com/g4/user/asofyeun - furaffinity: https://www.furaffinity.net/user/asofyeun + furaffinity: https://www.furaffinity.net/user/AsofYeun inkbunny: https://inkbunny.net/asofyeun sofurry: https://asofyeun.sofurry.com/ weasyl: https://www.weasyl.com/~asofyeun diff --git a/src/content/users/bad-manners.yaml b/src/content/users/bad-manners.yaml index 7da80c8..20f6f6d 100644 --- a/src/content/users/bad-manners.yaml +++ b/src/content/users/bad-manners.yaml @@ -1,6 +1,5 @@ -name: Bad Manners -lang: - eng: Bad Manners +name: + en: Bad Manners tok: nasin ike Pemene avatar: /src/assets/images/logo_bm.png links: @@ -9,8 +8,8 @@ links: furaffinity: https://www.furaffinity.net/user/BadManners inkbunny: https://inkbunny.net/BadManners sofurry: - - https://bad-manners.sofurry.com/ - - Bad Manners + link: https://bad-manners.sofurry.com/ + username: Bad Manners weasyl: https://www.weasyl.com/~BadManners twitter: https://twitter.com/BadManners__ mastodon: https://meow.social/@BadManners diff --git a/src/content/users/hans-woofington.yaml b/src/content/users/hans-woofington.yaml index dd8021d..24749fc 100644 --- a/src/content/users/hans-woofington.yaml +++ b/src/content/users/hans-woofington.yaml @@ -1,6 +1,6 @@ name: Dr. Hans Woofington links: furaffinity: - - https://www.furaffinity.net/user/hanslewdington/ - - Hans_Lewdington + link: https://www.furaffinity.net/user/hanslewdington/ + username: Hans_Lewdington preferredLink: furaffinity diff --git a/src/content/users/yolkmonkey.yaml b/src/content/users/yolkmonkey.yaml index fc08adf..54c2764 100644 --- a/src/content/users/yolkmonkey.yaml +++ b/src/content/users/yolkmonkey.yaml @@ -2,6 +2,6 @@ name: YolkMonkey links: furaffinity: https://furaffinity.net/user/Vampire101 sofurry: - - https://vampire101.sofurry.com/ - - Vampire101 + link: https://vampire101.sofurry.com/ + username: Vampire101 preferredLink: furaffinity diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 1958420..b63ef6a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -1,10 +1,11 @@ -import { type GamePlatform, type Lang } from "../content/config"; +import type { GamePlatform, Lang } from "../content/config"; import { DEFAULT_LANG } from "../content/config"; export { DEFAULT_LANG } from "../content/config"; const UI_STRINGS = { + // Utility functions "util/join_names": { - eng: (names: string[]) => + en: (names: string[]) => names.length <= 1 ? names.join("") : names.length == 2 @@ -13,10 +14,10 @@ const UI_STRINGS = { tok: (names: string[]) => names.join(" en "), }, "util/capitalize": { - eng: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""), + en: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""), }, "util/enumerate": { - eng: (count: number, nounSingular: string, nounPlural: string | undefined) => { + en: (count: number, nounSingular: string, nounPlural?: string) => { if (count == 0) { return `no ${nounPlural ?? nounSingular}`; } @@ -25,146 +26,205 @@ const UI_STRINGS = { } return `${count} ${nounPlural ?? nounSingular}`; }, - tok: (count: number, nounSingular: string, nounPlural: string | undefined) => + tok: (count: number, nounSingular: string, nounPlural?: string) => `${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`, }, - "export_story/writing": { - eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`, + // export-story API functions + "export_story/authors": { + en: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`, tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`, }, "export_story/request_for": { - eng: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`, + en: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`, }, "export_story/commissioned_by": { - eng: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`, + en: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`, }, - "story/return_to_stories": { - eng: "Return to stories", - tok: "o tawa e lipu ale", + // Shared strings for published content (stories + games) + "published_content/return_to_series": { + en: (seriesName: string) => `Return to ${seriesName}`, }, - "story/return_to_series": { - eng: (seriesName: string) => `Return to ${seriesName}`, - }, - "story/go_to_description": { - eng: "Go to description", + "published_content/go_to_description": { + en: "Go to description", tok: "o tawa e toki lipu", }, - "story/toggle_dark_mode": { - eng: "Toggle dark mode", + "published_content/toggle_dark_mode": { + en: "Toggle dark mode", tok: "o ante e kule lipu", }, + "published_content/cover_art_alt": { + en: (title: string) => `Cover art for ${title}`, + tok: (_title: string) => `sitelen tawa lipu ni`, + }, + "published_content/publish_date": { + en: (date: Date) => date.toISOString().slice(0, 10), + tok: (date: Date) => `tenpo suno ${date.toISOString().slice(0, 10)}`, + }, + "published_content/publish_date_aria_label": { + en: "Publish date", + tok: "tenpo pi pana lipu", + }, + "published_content/publish_date_aria_description": { + en: (date: Date) => + date.toLocaleDateString("en-US", { + month: "long", + day: "numeric", + year: "numeric", + }), + tok: (_date: Date) => "", + }, + "published_content/description": { + en: "Description", + tok: "toki lipu", + }, + "published_content/to_top": { + en: "To top", + tok: "tawa sewi", + }, + "published_content/tags": { + en: "Tags", + tok: "nimi kulupu", + }, + "published_content/copyright_year": { + en: (year: string | number) => `© ${year}`, + tok: (year: string | number) => `© tenpo pi sike suno ${year}`, + }, + "published_content/licenses": { + en: "Licenses", + tok: "lipu lawa", + }, + "published_content/draft_warning": { + en: "DRAFT VERSION – DO NOT REDISTRIBUTE", + }, + "published_content/related_stories": { + en: "Related stories", + }, + "published_content/related_games": { + en: "Related games", + }, + // Story page-specific strings + "story/return_to_stories": { + en: "Return to stories", + tok: "o tawa e lipu ale", + }, + "story/title_aria_label": { + en: "Story title", + tok: "nimi lipu", + }, + "story/authors_aria_label": { + en: (authors: any[]) => (authors.length == 1 ? "Author" : "Authors"), + tok: (_authors: any[]) => "jan pi pali lipu", + }, + "story/requesters_aria_label": { + en: (requesters: any[]) => (requesters.length == 1 ? "Requester" : "Requesters"), + }, + "story/commissioners_aria_label": { + en: (commissioners: any[]) => (commissioners.length == 1 ? "Commissioner" : "Commissioners"), + }, "story/warnings": { - eng: (wordCount: number | string | undefined, contentWarning: string) => + en: (wordCount: number | string | undefined, contentWarning: string) => wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning, tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning, }, - "story/publish_date": { - eng: (date: string) => date, - tok: (date: string) => `tenpo suno ${date}`, - }, - "story/description": { - eng: "Description", - tok: "toki lipu", + "story/article_aria_label": { + en: "Story", + tok: "lipu", }, "story/summary": { - eng: "Summary", + en: "Summary", tok: "lipu tawa tenpo lili", }, "story/reveal_summary": { - eng: "Click to reveal", + en: "Click to reveal", tok: "Click to reveal summary in English", }, - "story/to_top": { - eng: "To top", - tok: "tawa sewi", - }, - "story/tags": { - eng: "Tags", - tok: "nimi kulupu", - }, - "story/copyright_year": { - eng: (year: string | number) => `© ${year}`, - tok: (year: string | number) => `© tenpo pi sike suno ${year}`, - }, - "story/licenses": { - eng: "Licenses", - tok: "lipu lawa", - }, "story/authors": { - eng: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].eng(authorsList)}`, + en: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].en(authorsList)}`, tok: (authorsList: string[]) => authorsList.length > 1 ? `lipu ni li tan jan ni: ${UI_STRINGS["util/join_names"].tok(authorsList)}` : `lipu ni li tan ${authorsList[0]}`, }, "story/commissioned_by": { - eng: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`, + en: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].en(commissionersList)}`, }, "story/requested_by": { - eng: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`, + en: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].en(requestersList)}`, }, - "story/draft_warning": { - eng: "DRAFT VERSION – DO NOT REDISTRIBUTE", + // Game page-specific strings + "game/return_to_games": { + en: "Return to games", }, - "characters/characters_are_copyrighted_by": { - eng: (owner: string, charactersList: string[]) => - charactersList.length == 1 - ? `${charactersList[0]} is © ${owner}` - : `${UI_STRINGS["util/join_names"].eng(charactersList)} are © ${owner}`, - }, - "characters/all_characters_are_copyrighted_by": { - eng: (owner: string) => `All characters are © ${owner}`, + "game/title_aria_label": { + en: "Game title", }, "game/platforms": { - eng: (platforms: GamePlatform[]) => { + en: (platforms: GamePlatform[]) => { if (platforms.length == 0) { return ""; } const translatedPlatforms = platforms.map((platform) => { - const platformLang = UI_STRINGS[`game/platform_${platform}`].eng; + const platformLang = UI_STRINGS[`game/platform_${platform}`].en; if (!platformLang) { throw new Error(`Invalid platform "${platform}"`); } return platformLang; }); - return `A game for ${UI_STRINGS["util/join_names"].eng(translatedPlatforms)}.`; + return `A game for ${UI_STRINGS["util/join_names"].en(translatedPlatforms)}.`; }, }, "game/platform_web": { - eng: "web browsers", + en: "web browsers", }, "game/platform_windows": { - eng: "Windows", + en: "Windows", }, "game/platform_linux": { - eng: "Linux", + en: "Linux", }, "game/platform_macos": { - eng: "macOS", + en: "macOS", }, "game/platform_android": { - eng: "Android", + en: "Android", }, "game/platform_ios": { - eng: "iOS", + en: "iOS", }, "game/warnings": { - eng: (platforms: GamePlatform[], contentWarning: string) => - platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)} ${contentWarning}` : contentWarning, + en: (platforms: GamePlatform[], contentWarning: string) => + platforms.length > 0 ? `${UI_STRINGS["game/platforms"].en(platforms)} ${contentWarning}` : contentWarning, }, + "game/article_aria_label": { + en: "Game", + }, + // Copyrighted character-related strings + "characters/copyrighted_characters_aria_label": { + en: "Copyrighted characters", + }, + "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}`, + }, + // Tag-related strings "tag/total_works_with_tag": { - eng: (tag: string, storiesCount: number, gamesCount: number) => { + en: (tag: string, storiesCount: number, gamesCount: number) => { const content = []; if (storiesCount > 0) { - content.push(UI_STRINGS["util/enumerate"].eng(storiesCount, "story", "stories")); + content.push(UI_STRINGS["util/enumerate"].en(storiesCount, "story", "stories")); } if (gamesCount > 0) { - content.push(UI_STRINGS["util/enumerate"].eng(gamesCount, "game", "games")); + content.push(UI_STRINGS["util/enumerate"].en(gamesCount, "game", "games")); } if (content.length == 0) { return `No works tagged with "${tag}".`; } - return UI_STRINGS["util/capitalize"].eng(`${UI_STRINGS["util/join_names"].eng(content)} tagged with "${tag}".`); + return UI_STRINGS["util/capitalize"].en(`${UI_STRINGS["util/join_names"].en(content)} tagged with "${tag}".`); }, }, } as const; @@ -176,6 +236,9 @@ type TranslationEntry<T> = { [DEFAULT_LANG]: T } & { type TranslationArgs<K extends TranslationKey> = (typeof UI_STRINGS)[K] extends TranslationEntry<infer T> ? (T extends (...args: infer A) => string ? A : []) : never; +/** Translates some text according to the provided language, a translation key, + * and optionally any required arguments. + */ export function t<K extends TranslationKey>(lang: Lang, key: K, ...args: TranslationArgs<K>): string { if (key in UI_STRINGS) { const translation: string | ((...args: TranslationArgs<K>) => string) = diff --git a/src/layouts/BaseLayout.astro b/src/layouts/BaseLayout.astro index 6179484..3616a03 100644 --- a/src/layouts/BaseLayout.astro +++ b/src/layouts/BaseLayout.astro @@ -6,12 +6,13 @@ import AgeRestrictedModal from "../components/AgeRestrictedModal.astro"; type Props = { pageTitle?: string; + lang?: string; }; -const { pageTitle } = Astro.props; +const { pageTitle = "Gallery", lang = "en" } = Astro.props; --- -<html lang="en"> +<html lang={lang}> <head> <meta charset="UTF-8" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> @@ -23,7 +24,7 @@ const { pageTitle } = Astro.props; <meta name="theme-color" content="#ffffff" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="generator" content={Astro.generator} /> - <title>{pageTitle || "Gallery"} | Bad Manners</title> + <title>{pageTitle} | Bad Manners</title> <link rel="me" href="https://meow.social/@BadManners" /> <link rel="alternate" diff --git a/src/layouts/GameLayout.astro b/src/layouts/GameLayout.astro index 4dfc634..933c2bc 100644 --- a/src/layouts/GameLayout.astro +++ b/src/layouts/GameLayout.astro @@ -18,8 +18,8 @@ const { props } = Astro; const series = props.series && (await getEntry(props.series)); const authorsList = await getEntries(props.authors); const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters); -// const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft); -// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft); +const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft); +const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft); const categorizedTags = Object.fromEntries( (await getCollection("tag-categories")).flatMap((category) => category.data.tags.map<[string, string | null]>(({ name }) => @@ -44,10 +44,10 @@ const thumbnail = (await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight })); --- -<BaseLayout pageTitle={props.title}> +<BaseLayout pageTitle={props.title} lang={props.lang}> <Fragment slot="head"> <meta property="og:title" content={props.title} data-pagefind-meta="title[content]" /> - <meta property="og:description" content={props.contentWarning} /> + <meta property="og:description" content={t(props.lang, "game/warnings", props.platforms, props.contentWarning)} /> <meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" /> { thumbnail ? ( @@ -55,7 +55,7 @@ const thumbnail = <meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" /> <meta property="og:image:alt" - content={`Cover art for ${props.title}`} + content={t(props.lang, "published_content/cover_art_alt", props.title)} data-pagefind-meta="image_alt[content]" /> </Fragment> @@ -78,7 +78,9 @@ const thumbnail = <a href={series ? series.data.url : "/games"} class="text-link my-1 h-9 w-9 p-2" - aria-label={`Return to ${series ? series.data.name : "games"}`} + aria-label={series + ? t(props.lang, "published_content/return_to_series", series.data.name) + : t(props.lang, "game/return_to_games")} > <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true"> <path @@ -89,7 +91,7 @@ const thumbnail = <a href="#description" class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" - aria-label="Go to description" + aria-label={t(props.lang, "published_content/go_to_description")} > <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true"> <path @@ -100,7 +102,7 @@ const thumbnail = <button data-dark-mode class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" - aria-label="Toggle dark mode" + aria-label={t(props.lang, "published_content/toggle_dark_mode")} > <svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true"> <path @@ -120,7 +122,11 @@ const thumbnail = data-pagefind-body={props.isDraft ? undefined : ""} data-pagefind-meta="type:game" > - <h1 id="game-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"> + <h1 + id="game-title" + class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100" + aria-label={t(props.lang, "game/title_aria_label")} + > {props.title} </h1> <section @@ -136,7 +142,7 @@ const thumbnail = { props.isDraft ? ( <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600"> - {t(props.lang, "story/draft_warning")} + {t(props.lang, "published_content/draft_warning")} </p> ) : null } @@ -155,7 +161,7 @@ const thumbnail = <img loading="eager" src={thumbnail.src} - alt={`Cover art for ${props.title}`} + alt={t(props.lang, "published_content/cover_art_alt", props.title)} width={props.thumbnailWidth} height={props.thumbnailHeight} class="mx-auto my-5 shadow-lg" @@ -165,7 +171,7 @@ const thumbnail = ) : null } <hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> - <article id="game" class="pr-1 font-serif"> + <article id="game" class="pr-1 font-serif" aria-label={t(props.lang, "game/article_aria_label")}> <Prose> <slot /> </Prose> @@ -177,28 +183,26 @@ const thumbnail = id="draft-warning-bottom" class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600" > - {t(props.lang, "story/draft_warning")} + {t(props.lang, "published_content/draft_warning")} </p> ) : props.pubDate ? ( <p id="publish-date" class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200" - aria-label="Publish date" - aria-description={props.pubDate.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - })} + aria-label={t(props.lang, "published_content/publish_date_aria_label")} + aria-description={ + t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined + } data-pagefind-index-attrs="aria-description" data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`} > - {t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))} + {t(props.lang, "published_content/publish_date", props.pubDate)} </p> ) : null } <section id="description" class="px-2 font-serif" aria-describedby="title-description"> <h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> - {t(props.lang, "story/description")} + {t(props.lang, "published_content/description")} </h2> <Prose> <Markdown of={props.description} /> @@ -211,14 +215,50 @@ const thumbnail = ><path d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z" ></path></svg - ><span>{t(props.lang, "story/to_top")}</span></a + ><span>{t(props.lang, "published_content/to_top")}</span></a > </div> + { + relatedStories.length > 0 ? ( + <section id="related" aria-describedby="title-related" class="my-5"> + <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> + {t(props.lang, "published_content/related_stories")} + </h2> + <Prose> + <ul> + {relatedStories.map((story) => ( + <li> + <a href={`/stories/${story.slug}`}>{story.data.title}</a> + </li> + ))} + </ul> + </Prose> + </section> + ) : null + } + { + relatedGames.length > 0 ? ( + <section id="related" aria-describedby="title-related" class="my-5"> + <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> + {t(props.lang, "published_content/related_games")} + </h2> + <Prose> + <ul> + {relatedGames.map((game) => ( + <li> + <a href={`/games/${game.slug}`}>{game.data.title}</a> + </li> + ))} + </ul> + </Prose> + </section> + ) : null + } { tags.length > 0 ? ( <section id="tags" aria-describedby="title-tags" class="my-5"> <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> - Tags + {t(props.lang, "published_content/tags")} </h2> <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2"> {tags.map(({ id, name }) => ( @@ -232,16 +272,12 @@ const thumbnail = </section> ) : null } - <MastodonComments - instance={props.posts.mastodon?.instance} - user={props.posts.mastodon?.user} - postId={props.posts.mastodon?.postId} - /> + {props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null} </main> <div class="pt-6 text-center text-xs text-black dark:text-white"> - <span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span> + <span>{t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span> <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank" - >{t(props.lang, "story/licenses")}</a + >{t(props.lang, "published_content/licenses")}</a > </div> </div> diff --git a/src/layouts/StoryLayout.astro b/src/layouts/StoryLayout.astro index b3e50e6..b1ea437 100644 --- a/src/layouts/StoryLayout.astro +++ b/src/layouts/StoryLayout.astro @@ -17,21 +17,15 @@ import { formatCopyrightedCharacters } from "../utils/format_copyrighted_charact type Props = CollectionEntry<"stories">["data"]; const { props } = Astro; -let prev = props.prev && (await getEntry(props.prev)); -if (prev && prev.data.isDraft) { - prev = undefined; -} -let next = props.next && (await getEntry(props.next)); -if (next && next.data.isDraft) { - next = undefined; -} +const prev = props.prev && (await getEntry(props.prev)); +const next = props.next && (await getEntry(props.next)); const series = props.series && (await getEntry(props.series)); const authorsList = await getEntries(props.authors); const commissionersList = props.commissioner && (await getEntries(props.commissioner)); const requestersList = props.requester && (await getEntries(props.requester)); const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters); const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft); -// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft); +const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft); const categorizedTags = Object.fromEntries( (await getCollection("tag-categories")).flatMap((category) => category.data.tags.map<[string, string | null]>(({ name }) => @@ -57,7 +51,7 @@ const thumbnail = const wordCount = props.wordCount?.toString(); --- -<BaseLayout pageTitle={props.title}> +<BaseLayout pageTitle={props.title} lang={props.lang}> <Fragment slot="head"> <meta property="og:title" content={props.title} data-pagefind-meta="title[content]" /> <meta property="og:description" content={t(props.lang, "story/warnings", wordCount, props.contentWarning)} /> @@ -68,7 +62,7 @@ const wordCount = props.wordCount?.toString(); <meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" /> <meta property="og:image:alt" - content={`Cover art for ${props.shortTitle || props.title}`} + content={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)} data-pagefind-meta="image_alt[content]" /> </Fragment> @@ -92,7 +86,7 @@ const wordCount = props.wordCount?.toString(); href={series ? series.data.url : "/stories/1"} class="text-link my-1 h-9 w-9 p-2" aria-label={series - ? t(props.lang, "story/return_to_series", series.data.name) + ? t(props.lang, "published_content/return_to_series", series.data.name) : t(props.lang, "story/return_to_stories")} > <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true"> @@ -104,7 +98,7 @@ const wordCount = props.wordCount?.toString(); <a href="#description" class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" - aria-label={t(props.lang, "story/go_to_description")} + aria-label={t(props.lang, "published_content/go_to_description")} > <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true"> <path @@ -115,7 +109,7 @@ const wordCount = props.wordCount?.toString(); <button data-dark-mode class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" - aria-label={t(props.lang, "story/toggle_dark_mode")} + aria-label={t(props.lang, "published_content/toggle_dark_mode")} > <svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true"> <path @@ -136,7 +130,7 @@ const wordCount = props.wordCount?.toString(); data-pagefind-meta="type:story" > { - prev || next ? ( + (prev && !prev.data.isDraft) || (next && !next.data.isDraft) ? ( <div class="print:hidden"> <div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2"> {prev ? ( @@ -170,7 +164,11 @@ const wordCount = props.wordCount?.toString(); </div> ) : null } - <h1 id="story-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"> + <h1 + id="story-title" + class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100" + aria-label={t(props.lang, "story/title_aria_label")} + > {props.title} </h1> <section @@ -180,13 +178,6 @@ const wordCount = props.wordCount?.toString(); <Authors lang={props.lang}> {authorsList.map((author) => <UserComponent user={author} lang={props.lang} />)} </Authors> - { - props.isDraft ? ( - <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600"> - {t(props.lang, "story/draft_warning")} - </p> - ) : null - } { requestersList && ( <Requesters lang={props.lang}> @@ -205,6 +196,13 @@ const wordCount = props.wordCount?.toString(); </Commissioners> ) } + { + props.isDraft ? ( + <p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600"> + {t(props.lang, "published_content/draft_warning")} + </p> + ) : null + } <div id="content-warning"> <p> {t(props.lang, "story/warnings", wordCount, props.contentWarning)} @@ -218,7 +216,7 @@ const wordCount = props.wordCount?.toString(); <img loading="eager" src={thumbnail.src} - alt={`Cover art for ${props.shortTitle || props.title}`} + alt={t(props.lang, "published_content/cover_art_alt", props.shortTitle || props.title)} width={props.thumbnailWidth} height={props.thumbnailHeight} class="mx-auto my-5 shadow-lg" @@ -227,7 +225,7 @@ const wordCount = props.wordCount?.toString(); ) : null } <hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> - <article id="story" class="pr-1 font-serif"> + <article id="story" class="pr-1 font-serif" aria-label={t(props.lang, "story/article_aria_label")}> <Prose> <slot /> </Prose> @@ -239,28 +237,26 @@ const wordCount = props.wordCount?.toString(); id="draft-warning-bottom" class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600" > - {t(props.lang, "story/draft_warning")} + {t(props.lang, "published_content/draft_warning")} </p> ) : props.pubDate ? ( <p id="publish-date" class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200" - aria-label="Publish date" - aria-description={props.pubDate.toLocaleDateString("en-US", { - month: "long", - day: "numeric", - year: "numeric", - })} + aria-label={t(props.lang, "published_content/publish_date_aria_label")} + aria-description={ + t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined + } data-pagefind-index-attrs="aria-description" data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`} > - {t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))} + {t(props.lang, "published_content/publish_date", props.pubDate)} </p> ) : null } <section id="description" class="px-2 font-serif" aria-describedby="title-description"> <h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> - {t(props.lang, "story/description")} + {t(props.lang, "published_content/description")} </h2> <Prose> <Markdown of={props.description} /> @@ -292,7 +288,7 @@ const wordCount = props.wordCount?.toString(); ><path d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z" ></path></svg - ><span>{t(props.lang, "story/to_top")}</span></a + ><span>{t(props.lang, "published_content/to_top")}</span></a > </div> { @@ -335,13 +331,31 @@ const wordCount = props.wordCount?.toString(); relatedStories.length > 0 ? ( <section id="related" aria-describedby="title-related" class="my-5"> <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> - Related stories + {t(props.lang, "published_content/related_stories")} </h2> <Prose> <ul> - {relatedStories.map((stories) => ( + {relatedStories.map((story) => ( <li> - <a href={`/stories/${stories.slug}`}>{stories.data.title}</a> + <a href={`/stories/${story.slug}`}>{story.data.title}</a> + </li> + ))} + </ul> + </Prose> + </section> + ) : null + } + { + relatedGames.length > 0 ? ( + <section id="related" aria-describedby="title-related" class="my-5"> + <h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> + {t(props.lang, "published_content/related_games")} + </h2> + <Prose> + <ul> + {relatedGames.map((game) => ( + <li> + <a href={`/games/${game.slug}`}>{game.data.title}</a> </li> ))} </ul> @@ -353,7 +367,7 @@ const wordCount = props.wordCount?.toString(); tags.length > 0 ? ( <section id="tags" aria-describedby="title-tags" class="my-5"> <h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100"> - {t(props.lang, "story/tags")} + {t(props.lang, "published_content/tags")} </h2> <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2"> {tags.map(({ id, name }) => ( @@ -367,16 +381,12 @@ const wordCount = props.wordCount?.toString(); </section> ) : null } - <MastodonComments - instance={props.posts.mastodon?.instance} - user={props.posts.mastodon?.user} - postId={props.posts.mastodon?.postId} - /> + {props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null} </main> <div class="pt-6 text-center text-xs text-black dark:text-white"> - <span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span> + <span>{t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span> <a class="hover:underline focus:underline" href="/licenses.txt" target="_blank" - >{t(props.lang, "story/licenses")}</a + >{t(props.lang, "published_content/licenses")}</a > </div> </div> diff --git a/src/pages/api/export-story/[...slug].ts b/src/pages/api/export-story/[...slug].ts index f912a15..47eea8e 100644 --- a/src/pages/api/export-story/[...slug].ts +++ b/src/pages/api/export-story/[...slug].ts @@ -1,6 +1,6 @@ import type { APIRoute, GetStaticPaths } from "astro"; -import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; -import type { Website } from "../../../content/config"; +import { getCollection, type CollectionEntry, getEntries } from "astro:content"; +import type { Lang, Website } from "../../../content/config"; import { t } from "../../../i18n"; import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters"; import { markdownToBbcode } from "../../../utils/markdown_to_bbcode"; @@ -24,72 +24,8 @@ type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: in function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string { const link = user.data.links[website]; - if (link) { - if (typeof link === "string") { - switch (website) { - case "website": - break; - case "eka": - const ekaMatch = link.match(/^.*\baryion\.com\/g4\/user\/([^\/]+)\/?$/); - if (ekaMatch && ekaMatch[1]) { - return ekaMatch[1]; - } - break; - case "furaffinity": - const faMatch = link.match(/^.*\bfuraffinity\.net\/user\/([^\/]+)\/?$/); - if (faMatch && faMatch[1]) { - return faMatch[1]; - } - break; - case "inkbunny": - const ibMatch = link.match(/^.*\binkbunny\.net\/([^\/]+)\/?$/); - if (ibMatch && ibMatch[1]) { - return ibMatch[1]; - } - break; - case "sofurry": - const sfMatch = link.match(/^(?:https?:\/\/)?([^\.]+).sofurry.com\b.*$/); - if (sfMatch && sfMatch[1]) { - return sfMatch[1].replaceAll("-", " "); - } - break; - case "weasyl": - const weasylMatch = link.match(/^.*\bweasyl\.com\/\~([^\/]+)\/?$/); - if (weasylMatch && weasylMatch[1]) { - return weasylMatch[1]; - } - break; - case "twitter": - const twitterMatch = link.match(/^.*(?:\btwitter\.com|\bx\.com)\/@?([^\/]+)\/?$/); - if (twitterMatch && twitterMatch[1]) { - return twitterMatch[1]; - } - break; - case "mastodon": - const mastodonMatch = link.match(/^(?:https?\:\/\/)?([^\/]+)\/(?:@|users\/)([^\/]+)\/?$/); - if (mastodonMatch && mastodonMatch[1] && mastodonMatch[2]) { - return `${mastodonMatch[2]}@${mastodonMatch[1]}`; - } - break; - case "bluesky": - const bskyMatch = link.match(/^.*\bbsky\.app\/profile\/([^\/]+)\/?$/); - if (bskyMatch && bskyMatch[1]) { - return bskyMatch[1]; - } - break; - case "itaku": - const itakuMatch = link.match(/^.*\bitaku\.ee\/profile\/([^\/]+)\/?$/); - if (itakuMatch && itakuMatch[1]) { - return itakuMatch[1]; - } - break; - default: - let _: never = website; - throw new Error(`Unhandled website "${website}"`); - } - } else { - return link[1].replace(/^@/, ""); - } + if (link?.username) { + return link.username; } throw new Error(`Cannot get "${website}" username for user "${user.id}"`); } @@ -99,20 +35,21 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b return !preferredLink || preferredLink == website; } -function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string { +function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, lang: Lang): string { + const { links, preferredLink } = user.data; switch (website) { case "eka": - if ("eka" in user.data.links) { + if ("eka" in links) { return `:icon${getUsernameForWebsite(user, "eka")}:`; } break; case "furaffinity": - if ("furaffinity" in user.data.links) { + if ("furaffinity" in links) { return `:icon${getUsernameForWebsite(user, "furaffinity")}:`; } break; case "weasyl": - if ("weasyl" in user.data.links) { + if ("weasyl" in links) { return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`; } else if (isPreferredWebsite(user, "furaffinity")) { return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`; @@ -123,7 +60,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa } break; case "inkbunny": - if ("inkbunny" in user.data.links) { + if ("inkbunny" in links) { return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`; } else if (isPreferredWebsite(user, "furaffinity")) { return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`; @@ -134,7 +71,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa } break; case "sofurry": - if ("sofurry" in user.data.links) { + if ("sofurry" in links) { return `:icon${getUsernameForWebsite(user, "sofurry")}:`; } else if (isPreferredWebsite(user, "furaffinity")) { return `fa!${getUsernameForWebsite(user, "furaffinity")}`; @@ -143,11 +80,12 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa } break; default: - throw new Error(`Unhandled ExportWebsite "${website}"`); + const unknown: never = website; + throw new Error(`Unhandled export website "${unknown}"`); } - if (user.data.preferredLink) { - const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string]; - return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`; + 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)`, @@ -182,14 +120,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) = const description = Object.fromEntries( WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => { const u = (user: CollectionEntry<"users">) => - isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website); + isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang); const storyDescription = ( [ story.data.description, `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`, t( lang, - "export_story/writing", + "export_story/authors", authorsList.map((author) => u(author)), ), requestersList && @@ -216,12 +154,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) = /\[([^\]]+)\]\((\/[^\)]+)\)/g, (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`, ); - if (exportFormat === "bbcode") { - return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")]; - } else if (exportFormat === "markdown") { - return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()]; - } else { - throw new Error(`Unhandled export format "${exportFormat}"`); + 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}"`); } }), ); @@ -252,13 +192,12 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) = .replaceAll(/\n\n\n+/g, "\n\n") .trim(); - const headers = { "Content-Type": "application/json; charset=utf-8" }; return new Response( JSON.stringify({ story: storyText, description, thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null, }), - { headers }, + { headers: { "Content-Type": "application/json; charset=utf-8" } }, ); }; diff --git a/src/pages/api/healthcheck.ts b/src/pages/api/healthcheck.ts index cf9fedd..6f2f3c4 100644 --- a/src/pages/api/healthcheck.ts +++ b/src/pages/api/healthcheck.ts @@ -1,12 +1,10 @@ import type { APIRoute } from "astro"; -const content = { isAlive: true }; - -const headers = { "Content-Type": "application/json; charset=utf-8" }; - export const GET: APIRoute = () => { if (import.meta.env.PROD) { return new Response(null, { status: 404 }); } - return new Response(JSON.stringify(content), { headers }); + return new Response(JSON.stringify({ isAlive: true }), { + headers: { "Content-Type": "application/json; charset=utf-8" }, + }); }; diff --git a/src/pages/feed.xml.ts b/src/pages/feed.xml.ts index 76dda05..57785f3 100644 --- a/src/pages/feed.xml.ts +++ b/src/pages/feed.xml.ts @@ -24,8 +24,7 @@ function toNoonUTCDate(date: Date) { const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => { const userName = getUsernameForLang(user, lang); if (user.data.preferredLink) { - const link = user.data.links[user.data.preferredLink]!; - return `<a href="${typeof link === "string" ? link : link[0]}">${userName}</a>`; + return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`; } return userName; }; diff --git a/src/pages/games/[...slug].astro b/src/pages/games/[...slug].astro index 7c12085..4e3c03b 100644 --- a/src/pages/games/[...slug].astro +++ b/src/pages/games/[...slug].astro @@ -18,17 +18,6 @@ export const getStaticPaths: GetStaticPaths = async () => { }; const game = Astro.props; -if (!game.data.isDraft) { - if (!game.data.pubDate) { - throw new Error(`Missing "pubDate" for published game ${game.data.title} ("${game.slug}")`); - } - if (!game.data.thumbnail) { - throw new Error(`Missing "thumbnail" for published game ${game.data.title} ("${game.slug}")`); - } - if (game.data.tags.length == 0) { - throw new Error(`Missing "tags" for published game ${game.data.title} ("${game.slug}")`); - } -} const { Content } = await game.render(); --- diff --git a/src/pages/index.astro b/src/pages/index.astro index 2d35136..b70bd29 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,5 +1,5 @@ --- -import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content"; +import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content"; import { Image } from "astro:assets"; import GalleryLayout from "../layouts/GalleryLayout.astro"; import { t } from "../i18n"; diff --git a/src/pages/stories/[...slug].astro b/src/pages/stories/[...slug].astro index 39d5ab0..2f58526 100644 --- a/src/pages/stories/[...slug].astro +++ b/src/pages/stories/[...slug].astro @@ -20,21 +20,7 @@ export const getStaticPaths: GetStaticPaths = async () => { const story = Astro.props; const readingTime = getReadingTime(story.body); -if (!story.data.isDraft) { - if (!story.data.wordCount) { - throw new Error(`Missing "wordCount" for published story ${story.data.title} ("${story.slug}")`); - } - if (!story.data.pubDate) { - throw new Error(`Missing "pubDate" for published story ${story.data.title} ("${story.slug}")`); - } - if (!story.data.thumbnail) { - throw new Error(`Missing "thumbnail" for published story ${story.data.title} ("${story.slug}")`); - } - if (story.data.tags.length == 0) { - throw new Error(`Missing "tags" for published story ${story.data.title} ("${story.slug}")`); - } -} -if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 135) { +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}") ` + `(expected ~${story.data.wordCount}, found ${readingTime.words})`, diff --git a/src/pages/tags.astro b/src/pages/tags.astro index 32d1e6e..8c4577e 100644 --- a/src/pages/tags.astro +++ b/src/pages/tags.astro @@ -10,65 +10,53 @@ interface Tag { description?: string; } -const [stories, games, tagCategories] = await Promise.all([ +const [stories, games, tagCategories, seriesCollection] = await Promise.all([ getCollection("stories"), getCollection("games"), getCollection("tag-categories"), + getCollection("series"), ]); -const tagsSet = new Set<string>(); +const uncategorizedTagsSet = new Set<string>(); const draftOnlyTagsSet = new Set<string>(); -const seriesCollection = await getCollection("series"); -// Add tags from non-drafts to set; then, add tags only from drafts to separate set -[stories, games] - .flat() - .sort((a, b) => (a.data.isDraft ? 1 : b.data.isDraft ? -1 : 0)) - .forEach((value) => { - if (value.data.isDraft) { - value.data.tags.forEach((tag) => { - if (!tagsSet.has(tag)) { - draftOnlyTagsSet.add(tag); - } - }); - } else { - value.data.tags.forEach((tag) => { - tagsSet.add(tag); - }); - } - }); +[stories, games].flat().forEach(({ data: { isDraft, tags } }) => { + if (isDraft) { + tags.forEach((tag) => draftOnlyTagsSet.add(tag)); + } else { + tags.forEach((tag) => uncategorizedTagsSet.add(tag)); + } +}); +// Tags from published content shouldn't be included in drafts-only set +uncategorizedTagsSet.forEach((tag) => draftOnlyTagsSet.delete(tag)); -const uncategorizedTagsSet = new Set(tagsSet); +const uniqueSlugs = new Set<string>(); const categorizedTags = tagCategories .sort((a, b) => { if (a.data.index == b.data.index) { - throw new Error( - `Found tag categories with same index value ${a.data.index} ("${a.data.name}", "${b.data.name}")`, - ); + throw new Error(`Found tag categories with same index value ${a.data.index} ("${a.id}", "${b.id}")`); } return a.data.index - b.data.index; }) .map((category) => { const tagList = category.data.tags.map<Tag>(({ name, description }) => { description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " "); - const tag = typeof name === "string" ? name : name["eng"]; - const id = slug(tag); - return { id, name: tag, description }; + const tag = typeof name === "string" ? name : name.en; + return { id: slug(tag), name: tag, description }; }); - tagList.forEach(({ name }, index) => { - if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) { - throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`); + tagList.forEach(({ id, name }) => { + if (uniqueSlugs.has(id)) { + throw new Error(`Duplicated tag "${name}" found in multiple tag-categories entries`); } + uniqueSlugs.add(id); }); return { name: category.data.name, - id: category.id, + id: slug(category.data.name), tags: tagList.filter(({ name }) => { if (draftOnlyTagsSet.has(name)) { - console.log(`Omitting draft-only tag "${name}"`); + // console.log(`Omitting draft-only tag "${name}"`); return false; } - if (tagsSet.has(name)) { - uncategorizedTagsSet.delete(name); - } + uncategorizedTagsSet.delete(name); return true; }), }; diff --git a/src/pages/tags/[slug].astro b/src/pages/tags/[slug].astro index dfee5fb..67278cb 100644 --- a/src/pages/tags/[slug].astro +++ b/src/pages/tags/[slug].astro @@ -1,7 +1,7 @@ --- import type { GetStaticPaths } from "astro"; import { Image } from "astro:assets"; -import { type CollectionEntry, getCollection, type CollectionKey } from "astro:content"; +import { type CollectionEntry, type CollectionKey, getCollection } from "astro:content"; import { Markdown } from "@astropub/md"; import { slug } from "github-slugger"; import GalleryLayout from "../../layouts/GalleryLayout.astro"; @@ -45,24 +45,22 @@ export const getStaticPaths: GetStaticPaths = async () => { const tagDescriptions = tagCategories.reduce( (acc, category) => { category.data.tags.forEach(({ name, description, related }) => { - if (related) { - related = related.filter((relatedTag) => { - if (relatedTag == name) { - console.warn(`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`); - return false; - } - return true; - }); - } - const key = typeof name === "string" ? name : name["eng"]; + related = related.filter((relatedTag) => { + if (relatedTag == name) { + console.warn(`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`); + return false; + } + return true; + }); + const key = typeof name === "string" ? name : name.en; if (key in acc) { throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`); } - acc[key] = { description, related }; + acc[key] = { description, related: related.length > 0 ? related : undefined }; }); return acc; }, diff --git a/src/utils/get_username_for_lang.ts b/src/utils/get_username_for_lang.ts index c422cc1..f2cace8 100644 --- a/src/utils/get_username_for_lang.ts +++ b/src/utils/get_username_for_lang.ts @@ -2,14 +2,15 @@ import type { CollectionEntry } from "astro:content"; import { DEFAULT_LANG, type Lang } from "../content/config"; export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string { - if (user.data.lang) { - if (user.data.lang[lang]) { - return user.data.lang[lang]; + const { name } = user.data; + if (typeof name === "object") { + if (name[lang]) { + return name[lang]; } - throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`); + throw new Error(`No "${lang}" translation for user "${user.id}"`); } if (lang !== DEFAULT_LANG) { - console.warn(`User "${user.data.name}" ("${user.id}") has no "lang" property for a "${lang}" translation`); + console.warn(`Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default`); } - return user.data.name; + return name; } diff --git a/src/utils/is_anonymous_user.ts b/src/utils/is_anonymous_user.ts index 070adb0..57ed851 100644 --- a/src/utils/is_anonymous_user.ts +++ b/src/utils/is_anonymous_user.ts @@ -1,6 +1,6 @@ import type { CollectionEntry } from "astro:content"; -import { ANONYMOUS_USER_ID as ID } from "../content/config"; +import { ANONYMOUS_USER_ID } from "../content/config"; -const ANONYMOUS_USER_ID: CollectionEntry<"users">["id"] = ID; +const ID: CollectionEntry<"users">["id"] = ANONYMOUS_USER_ID; -export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id == ANONYMOUS_USER_ID; +export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id === ID; diff --git a/tsconfig.json b/tsconfig.json index bcbf8b5..da42df9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,3 +1,4 @@ { - "extends": "astro/tsconfigs/strict" + "extends": "astro/tsconfigs/strict", + "exclude": ["dist"] }