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 1dc1384..3f075b1 100755 Binary files a/src/assets/thumbnails/bm_20_playing_it_safe.png and b/src/assets/thumbnails/bm_20_playing_it_safe.png differ 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"] }