Several minor improvements to typing and misc.

- Improved schema validation
- Move username parsing and other validators to schema types
- Fix astro check command
- Add JSON/YAML schema validation for data collections
- Update licenses
- Remove deployment script in favor of rsync
- Prevent unsanitized input in export-story script
- Change "eng" language to "en", per BCP47
- Clean up i18n keys and add aria attributes
- Improve MastodonComments behavior on no-JS browsers
This commit is contained in:
Bad Manners 2024-08-07 19:25:50 -03:00
parent fe908a4989
commit 7bb8a952ef
54 changed files with 1005 additions and 962 deletions

View file

@ -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"
]
} }

19
.vscode/settings.json vendored
View file

@ -2,6 +2,25 @@
"files.associations": { "files.associations": {
"*.css": "tailwindcss" "*.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]": { "[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode" "editor.defaultFormatter": "esbenp.prettier-vscode"
}, },

21
LICENSE
View file

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

1
LICENSE.md Normal file
View file

@ -0,0 +1 @@
See [public/licenses.txt](public/licenses.txt)

View file

@ -5,7 +5,7 @@ Static website built in Astro + Typescript + TailwindCSS.
## Requirements ## Requirements
- Node.js 20+ - Node.js 20+
- (optional) LFTP, for the remote deployment script. - (optional) rsync, for remote deployment.
- (optional) LibreOffice, for the story export script. - (optional) LibreOffice, for the story export script.
## Development ## Development
@ -31,7 +31,7 @@ npm run prettier # Prettier formatting
Requires `libreoffice` to be installed and in your path. Requires `libreoffice` to be installed and in your path.
```bash ```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 ### 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 npm run build
``` ```
Then, if you're using LFTP: Then, after configuring the `gallerybm` host (or the name of your choosing) in `~/.ssh/config`:
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:
```bash ```bash
npm run deploy-lftp rsync --delete -acP dist/ gallerybm:/home/public
``` ```

View file

@ -19,6 +19,7 @@ export default defineConfig({
build: { build: {
assets: "assets", assets: "assets",
}, },
outDir: "./dist",
redirects: { redirects: {
"/stories": "/stories/1", "/stories": "/stories/1",
}, },

View file

@ -17,7 +17,7 @@ tags: []
# series: the-lost-of-the-marshes # series: the-lost-of-the-marshes
# relatedStories: [] # relatedStories: []
# relatedGames: [] # relatedGames: []
# lang: eng # lang: en
--- ---
The game content (i.e. embed) goes here. The game content (i.e. embed) goes here.

View file

@ -25,7 +25,7 @@ tags: []
# Some funny summary # Some funny summary
# relatedStories: [] # relatedStories: []
# relatedGames: [] # relatedGames: []
# lang: eng # lang: en
--- ---
The story goes here. The story goes here.

View file

@ -1,6 +1,6 @@
name: Nameless User name: Nameless User
nameLang: nameLang:
eng: Nameless en: Nameless
tok: jan Nenle pi nimi ala tok: jan Nenle pi nimi ala
# avatar: /src/assets/images/logo_bm.png # avatar: /src/assets/images/logo_bm.png
links: links:

307
package-lock.json generated
View file

@ -1,26 +1,26 @@
{ {
"name": "gallery-badmanners-xyz", "name": "gallery-badmanners-xyz",
"version": "1.6.0", "version": "1.6.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery-badmanners-xyz", "name": "gallery-badmanners-xyz",
"version": "1.6.0", "version": "1.6.1",
"dependencies": { "dependencies": {
"@astrojs/check": "^0.8.2", "@astrojs/check": "^0.9.2",
"@astrojs/rss": "^4.0.7", "@astrojs/rss": "^4.0.7",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@astropub/md": "^1.0.0", "@astropub/md": "^1.0.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"astro": "^4.12.2", "astro": "^4.13.1",
"astro-pagefind": "^1.6.0", "astro-pagefind": "^1.6.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"marked": "^12.0.1", "marked": "^12.0.2",
"pagefind": "^1.1.0", "pagefind": "^1.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"sanitize-html": "^2.13.0", "sanitize-html": "^2.13.0",
"tailwindcss": "^3.4.6", "tailwindcss": "^3.4.7",
"tiny-decode": "^0.1.3", "tiny-decode": "^0.1.3",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
@ -32,7 +32,7 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tsx": "^4.16.2" "tsx": "^4.16.5"
} }
}, },
"../astro-pagefind/packages/astro-pagefind": { "../astro-pagefind/packages/astro-pagefind": {
@ -83,12 +83,12 @@
} }
}, },
"node_modules/@astrojs/check": { "node_modules/@astrojs/check": {
"version": "0.8.2", "version": "0.9.2",
"resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.8.2.tgz", "resolved": "https://registry.npmjs.org/@astrojs/check/-/check-0.9.2.tgz",
"integrity": "sha512-L0V9dGb2PGvK9Mf3kby99Y+qm7EqxaC9tN1MVCvaqp/3pPPZBadR4XAySHipxXqQsxwJS25WQow8/1kMl1e25g==", "integrity": "sha512-6rWxtJTbd/ctdAlmla0CAvloGaai5IUTG0K21kctJHHGKJKnGH6Xana7m0zNOtHpVPEJi1SgC/TcsN+ltYt0Cg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@astrojs/language-server": "^2.12.1", "@astrojs/language-server": "^2.13.2",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"fast-glob": "^3.3.1", "fast-glob": "^3.3.1",
"kleur": "^4.1.5", "kleur": "^4.1.5",
@ -102,9 +102,9 @@
} }
}, },
"node_modules/@astrojs/compiler": { "node_modules/@astrojs/compiler": {
"version": "2.9.2", "version": "2.10.2",
"resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.9.2.tgz", "resolved": "https://registry.npmjs.org/@astrojs/compiler/-/compiler-2.10.2.tgz",
"integrity": "sha512-Vpu0Ffsj8SoV+N0DFHlxxOMKHwSC9059Xy/OlG1t6uFYSoJXxkBC2WyF6igO7x10V+8uJrhOxaXr3nA90kJXow==", "integrity": "sha512-bvH+v8AirwpRWCkYJEyWYdc5Cs/BjG2ZTxIJzttHilXgfKJAdW2496KsUQKzf5j2tOHtaHXKKn9hb9WZiBGpEg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@astrojs/internal-helpers": { "node_modules/@astrojs/internal-helpers": {
@ -114,12 +114,12 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@astrojs/language-server": { "node_modules/@astrojs/language-server": {
"version": "2.12.1", "version": "2.13.2",
"resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.12.1.tgz", "resolved": "https://registry.npmjs.org/@astrojs/language-server/-/language-server-2.13.2.tgz",
"integrity": "sha512-CCibE6XwSmrZEKlPDr48LZJN7NWxOurOJK1yOzqZFMNV8Y6DIqF6s1e60gbNNHMZkthWYBNTPno4Ni/XyviinQ==", "integrity": "sha512-l435EZLKjaUO/6iewJ7xqd3eHf3zAosVWG4woILbxluQcianBoNPepnnqAg7uUriZUaC44ae5v0Q+AfB8UI64g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@astrojs/compiler": "^2.9.1", "@astrojs/compiler": "^2.10.2",
"@jridgewell/sourcemap-codec": "^1.4.15", "@jridgewell/sourcemap-codec": "^1.4.15",
"@volar/kit": "~2.4.0-alpha.15", "@volar/kit": "~2.4.0-alpha.15",
"@volar/language-core": "~2.4.0-alpha.15", "@volar/language-core": "~2.4.0-alpha.15",
@ -256,30 +256,30 @@
} }
}, },
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.24.9", "version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.9.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz",
"integrity": "sha512-e701mcfApCJqMMueQI0Fb68Amflj83+dvAvHawoBpAz+GDjCIyGHzNwnefjsWJ3xiYAqqiQFoWbspGYBdb2/ng==", "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/core": { "node_modules/@babel/core": {
"version": "7.24.9", "version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
"integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.2.0", "@ampproject/remapping": "^2.2.0",
"@babel/code-frame": "^7.24.7", "@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.24.9", "@babel/generator": "^7.25.0",
"@babel/helper-compilation-targets": "^7.24.8", "@babel/helper-compilation-targets": "^7.25.2",
"@babel/helper-module-transforms": "^7.24.9", "@babel/helper-module-transforms": "^7.25.2",
"@babel/helpers": "^7.24.8", "@babel/helpers": "^7.25.0",
"@babel/parser": "^7.24.8", "@babel/parser": "^7.25.0",
"@babel/template": "^7.24.7", "@babel/template": "^7.25.0",
"@babel/traverse": "^7.24.8", "@babel/traverse": "^7.25.2",
"@babel/types": "^7.24.9", "@babel/types": "^7.25.2",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"debug": "^4.1.0", "debug": "^4.1.0",
"gensync": "^1.0.0-beta.2", "gensync": "^1.0.0-beta.2",
@ -304,12 +304,12 @@
} }
}, },
"node_modules/@babel/generator": { "node_modules/@babel/generator": {
"version": "7.24.10", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.10.tgz", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz",
"integrity": "sha512-o9HBZL1G2129luEUlG1hB4N/nlYNWHnpwlND9eOMclRqqu1YDy2sSYVCFUZwl8I1Gxh+QSRrP2vD7EpUmFVXxg==", "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/types": "^7.24.9", "@babel/types": "^7.25.0",
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25", "@jridgewell/trace-mapping": "^0.3.25",
"jsesc": "^2.5.1" "jsesc": "^2.5.1"
@ -331,12 +331,12 @@
} }
}, },
"node_modules/@babel/helper-compilation-targets": { "node_modules/@babel/helper-compilation-targets": {
"version": "7.24.8", "version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.8.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
"integrity": "sha512-oU+UoqCHdp+nWVDkpldqIQL/i/bvAv53tRqLG/s+cOXxe66zOYLU7ar/Xs3LdmBihrUMEUhwu6dMZwbNOYDwvw==", "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/compat-data": "^7.24.8", "@babel/compat-data": "^7.25.2",
"@babel/helper-validator-option": "^7.24.8", "@babel/helper-validator-option": "^7.24.8",
"browserslist": "^4.23.1", "browserslist": "^4.23.1",
"lru-cache": "^5.1.1", "lru-cache": "^5.1.1",
@ -355,43 +355,6 @@
"semver": "bin/semver.js" "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": { "node_modules/@babel/helper-module-imports": {
"version": "7.24.7", "version": "7.24.7",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", "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": { "node_modules/@babel/helper-module-transforms": {
"version": "7.24.9", "version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.9.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz",
"integrity": "sha512-oYbh+rtFKj/HwBQkFlUzvcybzklmVdVV3UU+mN7n2t/q3yGHbuVdNxyFvSBO1tfvjyArpHNcWMAzsSPdyI46hw==", "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-environment-visitor": "^7.24.7",
"@babel/helper-module-imports": "^7.24.7", "@babel/helper-module-imports": "^7.24.7",
"@babel/helper-simple-access": "^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": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -446,18 +408,6 @@
"node": ">=6.9.0" "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": { "node_modules/@babel/helper-string-parser": {
"version": "7.24.8", "version": "7.24.8",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz",
@ -486,13 +436,13 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.24.8", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.8.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz",
"integrity": "sha512-gV2265Nkcz7weJJfvDoAEVzC1e2OTDpkGbEsebse8koXUJUXPsCMi7sRo/+SPMuMZ9MtUPnGwITTnQnU5YjyaQ==", "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.24.7", "@babel/template": "^7.25.0",
"@babel/types": "^7.24.8" "@babel/types": "^7.25.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -514,10 +464,13 @@
} }
}, },
"node_modules/@babel/parser": { "node_modules/@babel/parser": {
"version": "7.24.8", "version": "7.25.3",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.8.tgz", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz",
"integrity": "sha512-WzfbgXOkGzZiXXCqk43kKwZjzwx4oulxZi3nq2TYL9mOjQv6kYwul9mz6ID36njuL7Xkp6nJEfok848Zj10j/w==", "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==",
"license": "MIT", "license": "MIT",
"dependencies": {
"@babel/types": "^7.25.2"
},
"bin": { "bin": {
"parser": "bin/babel-parser.js" "parser": "bin/babel-parser.js"
}, },
@ -541,16 +494,16 @@
} }
}, },
"node_modules/@babel/plugin-transform-react-jsx": { "node_modules/@babel/plugin-transform-react-jsx": {
"version": "7.24.7", "version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.24.7.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.2.tgz",
"integrity": "sha512-+Dj06GDZEFRYvclU6k4bme55GKBEWUmByM/eoKuqg4zTNQHiApWRhQph5fxQB2wAEFvRzL1tOEj1RJ19wJrhoA==", "integrity": "sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-annotate-as-pure": "^7.24.7", "@babel/helper-annotate-as-pure": "^7.24.7",
"@babel/helper-module-imports": "^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/plugin-syntax-jsx": "^7.24.7",
"@babel/types": "^7.24.7" "@babel/types": "^7.25.2"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -560,33 +513,30 @@
} }
}, },
"node_modules/@babel/template": { "node_modules/@babel/template": {
"version": "7.24.7", "version": "7.25.0",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz",
"integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.24.7", "@babel/code-frame": "^7.24.7",
"@babel/parser": "^7.24.7", "@babel/parser": "^7.25.0",
"@babel/types": "^7.24.7" "@babel/types": "^7.25.0"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@babel/traverse": { "node_modules/@babel/traverse": {
"version": "7.24.8", "version": "7.25.3",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.8.tgz", "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz",
"integrity": "sha512-t0P1xxAPzEDcEPmjprAQq19NWum4K0EQPjMwZQZbHt+GiZqvjCHjj755Weq1YRPVzBI+3zSfvScfpnuIecVFJQ==", "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.24.7", "@babel/code-frame": "^7.24.7",
"@babel/generator": "^7.24.8", "@babel/generator": "^7.25.0",
"@babel/helper-environment-visitor": "^7.24.7", "@babel/parser": "^7.25.3",
"@babel/helper-function-name": "^7.24.7", "@babel/template": "^7.25.0",
"@babel/helper-hoist-variables": "^7.24.7", "@babel/types": "^7.25.2",
"@babel/helper-split-export-declaration": "^7.24.7",
"@babel/parser": "^7.24.8",
"@babel/types": "^7.24.8",
"debug": "^4.3.1", "debug": "^4.3.1",
"globals": "^11.1.0" "globals": "^11.1.0"
}, },
@ -595,9 +545,9 @@
} }
}, },
"node_modules/@babel/types": { "node_modules/@babel/types": {
"version": "7.24.9", "version": "7.25.2",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.9.tgz", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz",
"integrity": "sha512-xm8XrMKz0IlUdocVbYJe0Z9xEgidU7msskG8BbhnTPK/HZ2z/7FP7ykqPgrUH+C+r414mNfNWam1f2vqOjqjYQ==", "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/helper-string-parser": "^7.24.8", "@babel/helper-string-parser": "^7.24.8",
@ -1582,9 +1532,10 @@
} }
}, },
"node_modules/@jridgewell/sourcemap-codec": { "node_modules/@jridgewell/sourcemap-codec": {
"version": "1.4.15", "version": "1.5.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz",
"integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==",
"license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.25", "version": "0.3.25",
@ -1863,9 +1814,9 @@
] ]
}, },
"node_modules/@shikijs/core": { "node_modules/@shikijs/core": {
"version": "1.11.1", "version": "1.12.1",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-1.12.1.tgz",
"integrity": "sha512-Qsn8h15SWgv5TDRoDmiHNzdQO2BxDe86Yq6vIHf5T0cCvmfmccJKIzHtep8bQO9HMBZYCtCBzaXdd1MnxZBPSg==", "integrity": "sha512-biCz/mnkMktImI6hMfMX3H9kOeqsInxWEyCHbSlL8C/2TR1FqfmGxTLRNwYCKsyCyxWLbB8rEqXRVZuyxuLFmA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/hast": "^3.0.4" "@types/hast": "^3.0.4"
@ -2285,34 +2236,33 @@
} }
}, },
"node_modules/astro": { "node_modules/astro": {
"version": "4.12.2", "version": "4.13.1",
"resolved": "https://registry.npmjs.org/astro/-/astro-4.12.2.tgz", "resolved": "https://registry.npmjs.org/astro/-/astro-4.13.1.tgz",
"integrity": "sha512-l6OmqlL+FiuSi9x6F+EGZitteOznq1JffOil7st7cdqeMCTEIym4oagI1a6zp6QekliKWEEZWdplGhgh1k1f7Q==", "integrity": "sha512-VnMjAc+ykFsIVjgbu9Mt/EA1fMIcPMXbU89h3ATwGOzSIKDsQH72bDgfJkWiwk6u0OE90GeP5EPhAT28Twf9oA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@astrojs/compiler": "^2.9.0", "@astrojs/compiler": "^2.10.0",
"@astrojs/internal-helpers": "0.4.1", "@astrojs/internal-helpers": "0.4.1",
"@astrojs/markdown-remark": "5.2.0", "@astrojs/markdown-remark": "5.2.0",
"@astrojs/telemetry": "3.1.0", "@astrojs/telemetry": "3.1.0",
"@babel/core": "^7.24.9", "@babel/core": "^7.25.2",
"@babel/generator": "^7.24.10", "@babel/generator": "^7.25.0",
"@babel/parser": "^7.24.8", "@babel/parser": "^7.25.3",
"@babel/plugin-transform-react-jsx": "^7.24.7", "@babel/plugin-transform-react-jsx": "^7.25.2",
"@babel/traverse": "^7.24.8", "@babel/traverse": "^7.25.3",
"@babel/types": "^7.24.9", "@babel/types": "^7.25.2",
"@types/babel__core": "^7.20.5", "@types/babel__core": "^7.20.5",
"@types/cookie": "^0.6.0", "@types/cookie": "^0.6.0",
"acorn": "^8.12.1", "acorn": "^8.12.1",
"aria-query": "^5.3.0", "aria-query": "^5.3.0",
"axobject-query": "^4.1.0", "axobject-query": "^4.1.0",
"boxen": "7.1.1", "boxen": "7.1.1",
"chokidar": "^3.6.0",
"ci-info": "^4.0.0", "ci-info": "^4.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"common-ancestor-path": "^1.0.1", "common-ancestor-path": "^1.0.1",
"cookie": "^0.6.0", "cookie": "^0.6.0",
"cssesc": "^3.0.0", "cssesc": "^3.0.0",
"debug": "^4.3.5", "debug": "^4.3.6",
"deterministic-object-hash": "^2.0.2", "deterministic-object-hash": "^2.0.2",
"devalue": "^5.0.0", "devalue": "^5.0.0",
"diff": "^5.2.0", "diff": "^5.2.0",
@ -2330,7 +2280,7 @@
"http-cache-semantics": "^4.1.1", "http-cache-semantics": "^4.1.1",
"js-yaml": "^4.1.0", "js-yaml": "^4.1.0",
"kleur": "^4.1.5", "kleur": "^4.1.5",
"magic-string": "^0.30.10", "magic-string": "^0.30.11",
"mrmime": "^2.0.0", "mrmime": "^2.0.0",
"ora": "^8.0.1", "ora": "^8.0.1",
"p-limit": "^6.1.0", "p-limit": "^6.1.0",
@ -2339,19 +2289,19 @@
"preferred-pm": "^4.0.0", "preferred-pm": "^4.0.0",
"prompts": "^2.4.2", "prompts": "^2.4.2",
"rehype": "^13.0.1", "rehype": "^13.0.1",
"semver": "^7.6.2", "semver": "^7.6.3",
"shiki": "^1.10.3", "shiki": "^1.12.0",
"string-width": "^7.2.0", "string-width": "^7.2.0",
"strip-ansi": "^7.1.0", "strip-ansi": "^7.1.0",
"tsconfck": "^3.1.1", "tsconfck": "^3.1.1",
"unist-util-visit": "^5.0.0", "unist-util-visit": "^5.0.0",
"vfile": "^6.0.2", "vfile": "^6.0.2",
"vite": "^5.3.4", "vite": "^5.3.5",
"vitefu": "^0.2.5", "vitefu": "^0.2.5",
"which-pm": "^3.0.0", "which-pm": "^3.0.0",
"yargs-parser": "^21.1.1", "yargs-parser": "^21.1.1",
"zod": "^3.23.8", "zod": "^3.23.8",
"zod-to-json-schema": "^3.23.1" "zod-to-json-schema": "^3.23.2"
}, },
"bin": { "bin": {
"astro": "astro.js" "astro": "astro.js"
@ -2958,9 +2908,9 @@
} }
}, },
"node_modules/debug": { "node_modules/debug": {
"version": "4.3.5", "version": "4.3.6",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz",
"integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "integrity": "sha512-O/09Bd4Z1fBrU4VzkhFqVgpPzaGbw6Sm9FEkBT1A/YBXQFGuuSxa1dN2nxgxS34JmKXqYx8CZAwEVoJFImUXIg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "2.1.2" "ms": "2.1.2"
@ -4270,12 +4220,12 @@
} }
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.10", "version": "0.30.11",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.11.tgz",
"integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.5.0"
} }
}, },
"node_modules/make-error": { "node_modules/make-error": {
@ -4295,9 +4245,10 @@
} }
}, },
"node_modules/marked": { "node_modules/marked": {
"version": "12.0.1", "version": "12.0.2",
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.1.tgz", "resolved": "https://registry.npmjs.org/marked/-/marked-12.0.2.tgz",
"integrity": "sha512-Y1/V2yafOcOdWQCX0XpAKXzDakPOpn6U0YLxTJs3cww6VxOzZV1BTOOYWLvH3gX38cq+iLwljHHTnMtlDfg01Q==", "integrity": "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q==",
"license": "MIT",
"bin": { "bin": {
"marked": "bin/marked.js" "marked": "bin/marked.js"
}, },
@ -6336,12 +6287,12 @@
} }
}, },
"node_modules/shiki": { "node_modules/shiki": {
"version": "1.11.1", "version": "1.12.1",
"resolved": "https://registry.npmjs.org/shiki/-/shiki-1.11.1.tgz", "resolved": "https://registry.npmjs.org/shiki/-/shiki-1.12.1.tgz",
"integrity": "sha512-VHD3Q0EBXaaa245jqayBe5zQyMQUdXBFjmGr9MpDaDpAKRMYn7Ff00DM5MLk26UyKjnml3yQ0O2HNX7PtYVNFQ==", "integrity": "sha512-nwmjbHKnOYYAe1aaQyEBHvQymJgfm86ZSS7fT8OaPRr4sbAcBNz7PbfAikMEFSDQ6se2j2zobkXvVKcBOm0ysg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@shikijs/core": "1.11.1", "@shikijs/core": "1.12.1",
"@types/hast": "^3.0.4" "@types/hast": "^3.0.4"
} }
}, },
@ -6616,9 +6567,9 @@
} }
}, },
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.6", "version": "3.4.7",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.6.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.7.tgz",
"integrity": "sha512-1uRHzPB+Vzu57ocybfZ4jh5Q3SdlH7XW23J5sQoM9LhE9eIOlzxer/3XPSsycvih3rboRsvt0QCmzSrqyOYUIA==", "integrity": "sha512-rxWZbe87YJb4OcSopb7up2Ba4U82BoiSGUdoDr3Ydrg9ckxFS/YWsvhN323GMcddgU65QRy7JndC7ahhInhvlQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@alloc/quick-lru": "^5.2.0", "@alloc/quick-lru": "^5.2.0",
@ -6838,9 +6789,9 @@
"optional": true "optional": true
}, },
"node_modules/tsx": { "node_modules/tsx": {
"version": "4.16.2", "version": "4.16.5",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.2.tgz", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.16.5.tgz",
"integrity": "sha512-C1uWweJDgdtX2x600HjaFaucXTilT7tgUZHbOE4+ypskZ1OP8CRCSDkCxG6Vya9EwaFIVagWwpaVAn5wzypaqQ==", "integrity": "sha512-ArsiAQHEW2iGaqZ8fTA1nX0a+lN5mNTyuGRRO6OW3H/Yno1y9/t1f9YOI1Cfoqz63VAthn++ZYcbDP7jPflc+A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -7122,9 +7073,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "5.3.4", "version": "5.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.4.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.5.tgz",
"integrity": "sha512-Cw+7zL3ZG9/NZBB8C+8QbQZmR54GwqIz+WMI4b3JgdYJvX+ny9AjJXqkGQlDXSXRP9rP0B4tbciRMOVEKulVOA==", "integrity": "sha512-MdjglKR6AQXQb9JGiS7Rc2wC6uMjcm7Go/NHNO63EwiJXfuk9PgqiP/n5IDJCziMkfw9n4Ubp7lttNwz+8ZVKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
@ -7363,9 +7314,9 @@
} }
}, },
"node_modules/vscode-languageserver-textdocument": { "node_modules/vscode-languageserver-textdocument": {
"version": "1.0.11", "version": "1.0.12",
"resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.11.tgz", "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz",
"integrity": "sha512-X+8T3GoiwTVlJbicx/sIAF+yuJAqz8VvwJyoMVhwEMoEKE/fkDmrqUgDMyBECcM2A2frVZIUj5HI/ErRXCfOeA==", "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/vscode-languageserver-types": { "node_modules/vscode-languageserver-types": {
@ -7716,9 +7667,9 @@
} }
}, },
"node_modules/zod-to-json-schema": { "node_modules/zod-to-json-schema": {
"version": "3.23.1", "version": "3.23.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.1.tgz", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.23.2.tgz",
"integrity": "sha512-oT9INvydob1XV0v1d2IadrR74rLtDInLvDFfAa1CG0Pmg/vxATk7I2gSelfj271mbzeM4Da0uuDQE/Nkj3DWNw==", "integrity": "sha512-uSt90Gzc/tUfyNqxnjlfBs8W6WSGpNBv0rVsNxP/BVSMHMKGdthPYff4xtCHYloJGM0CFxFsb3NbC0eqPhfImw==",
"license": "ISC", "license": "ISC",
"peerDependencies": { "peerDependencies": {
"zod": "^3.23.3" "zod": "^3.23.3"

View file

@ -1,33 +1,32 @@
{ {
"name": "gallery-badmanners-xyz", "name": "gallery-badmanners-xyz",
"type": "module", "type": "module",
"version": "1.6.0", "version": "1.6.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
"build": "npm run check && astro build", "build": "astro check && astro build",
"preview": "astro preview", "preview": "astro preview",
"sync": "astro sync", "sync": "astro sync",
"check": "astro check --minimumSeverity warning", "check": "astro check",
"astro": "astro", "astro": "astro",
"prettier": "prettier --write .", "prettier": "prettier --write .",
"deploy-lftp": "dotenv tsx scripts/deploy-lftp.ts --",
"export-story": "tsx scripts/export-story.ts" "export-story": "tsx scripts/export-story.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "^0.8.2", "@astrojs/check": "^0.9.2",
"@astrojs/rss": "^4.0.7", "@astrojs/rss": "^4.0.7",
"@astrojs/tailwind": "^5.1.0", "@astrojs/tailwind": "^5.1.0",
"@astropub/md": "^1.0.0", "@astropub/md": "^1.0.0",
"@tailwindcss/typography": "^0.5.13", "@tailwindcss/typography": "^0.5.13",
"astro": "^4.12.2", "astro": "^4.13.1",
"astro-pagefind": "^1.6.0", "astro-pagefind": "^1.6.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"marked": "^12.0.1", "marked": "^12.0.2",
"pagefind": "^1.1.0", "pagefind": "^1.1.0",
"reading-time": "^1.5.0", "reading-time": "^1.5.0",
"sanitize-html": "^2.13.0", "sanitize-html": "^2.13.0",
"tailwindcss": "^3.4.6", "tailwindcss": "^3.4.7",
"tiny-decode": "^0.1.3", "tiny-decode": "^0.1.3",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
@ -39,6 +38,6 @@
"prettier": "^3.3.3", "prettier": "^3.3.3",
"prettier-plugin-astro": "^0.14.1", "prettier-plugin-astro": "^0.14.1",
"prettier-plugin-tailwindcss": "^0.6.5", "prettier-plugin-tailwindcss": "^0.6.5",
"tsx": "^4.16.2" "tsx": "^4.16.5"
} }
} }

View file

@ -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 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 CC-BY-4.0: https://creativecommons.org/licenses/by/4.0/
The generic SVG icons were created by Font Awesome and are distributed under the CC-BY-4.0 license.
All third-party trademarks and attributed characters belong to their respective owners, and I'm not affiliated with any of them. 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 });
};

View file

@ -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();

View file

@ -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 { readdir, mkdir, mkdtemp, writeFile, readFile, copyFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join as pathJoin, normalize } from "node:path"; 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("*", "\\*")); await writeFile(pathJoin(outputDir, `${slug}.md`), storyText.replaceAll(/=(?==)/g, "= ").replaceAll("*", "\\*"));
const tempDir = await mkdtemp(pathJoin(tmpdir(), "export-story-")); const tempDir = await mkdtemp(pathJoin(tmpdir(), "export-story-"));
await writeFile(pathJoin(tempDir, "temp.txt"), storyText.replaceAll(/\n\n+/g, "\n")); 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 rtfText = await readFile(pathJoin(tempDir, "temp.rtf"), "utf-8");
const rtfStyles = getRTFStyles(rtfText); const rtfStyles = getRTFStyles(rtfText);
await writeFile( await writeFile(
@ -158,7 +164,6 @@ async function exportStory(slug: string, options: { outputDir: string }) {
rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]), rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]),
); );
console.log("Success!"); console.log("Success!");
process.exit(0);
} }
await program await program

Binary file not shown.

Before

(image error) Size: 22 KiB

After

(image error) Size: 20 KiB

Before After
Before After

View file

@ -54,11 +54,13 @@
(function () { (function () {
if (localStorage.getItem("ageVerified") !== "true") { if (localStorage.getItem("ageVerified") !== "true") {
document.body.appendChild( 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 modal = document.querySelector<HTMLElementTagNameMap["div"]>("body > div#modal-age-restricted")!;
const rejectButton = modal.querySelector<HTMLButtonElement>("button[data-modal-reject]")!; const rejectButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-reject]")!;
const acceptButton = modal.querySelector<HTMLButtonElement>("button[data-modal-accept]")!; const acceptButton = modal.querySelector<HTMLElementTagNameMap["button"]>("button[data-modal-accept]")!;
function onRejectButtonClick(e: MouseEvent) { function onRejectButtonClick(e: MouseEvent) {
e.preventDefault(); e.preventDefault();
location.href = "about:blank"; location.href = "about:blank";

View file

@ -1,5 +1,5 @@
--- ---
import { type Lang } from "../content/config"; import type { Lang } from "../content/config";
import { t } from "../i18n"; import { t } from "../i18n";
type Props = { 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
}

View file

@ -1,5 +1,5 @@
--- ---
import { type Lang } from "../content/config"; import type { Lang } from "../content/config";
import { t } from "../i18n"; import { t } from "../i18n";
type Props = { 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
}

View file

@ -1,6 +1,6 @@
--- ---
import { type CollectionEntry } from "astro:content"; import type { CollectionEntry } from "astro:content";
import { type Lang } from "../content/config"; import type { Lang } from "../content/config";
import { t } from "../i18n"; import { t } from "../i18n";
import UserComponent from "./UserComponent.astro"; import UserComponent from "./UserComponent.astro";
import CopyrightedCharactersItem from "./CopyrightedCharactersItem.astro"; import CopyrightedCharactersItem from "./CopyrightedCharactersItem.astro";
@ -15,7 +15,7 @@ const { copyrightedCharacters, lang } = Astro.props;
{ {
copyrightedCharacters ? ( copyrightedCharacters ? (
<section id="copyrighted-characters"> <section id="copyrighted-characters" aria-label={t(lang, "characters/copyrighted_characters_aria_label")}>
<ul> <ul>
{copyrightedCharacters.map(([owner, characterList]) => ( {copyrightedCharacters.map(([owner, characterList]) => (
<CopyrightedCharactersItem <CopyrightedCharactersItem

View file

@ -6,12 +6,12 @@
<script> <script>
(function () { (function () {
var colorScheme = localStorage.getItem("colorScheme"); let colorScheme = localStorage.getItem("colorScheme");
if (colorScheme == null || colorScheme === "auto") { if (colorScheme == null || colorScheme === "auto") {
colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; colorScheme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
} }
document.querySelectorAll("button[data-dark-mode]").forEach(function (button) { document.querySelectorAll<HTMLElementTagNameMap["button"]>("button[data-dark-mode]").forEach((button) => {
button.addEventListener("click", function (e) { button.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
if (colorScheme === "dark") { if (colorScheme === "dark") {
colorScheme = "light"; colorScheme = "light";

View file

@ -1,37 +1,47 @@
--- ---
import type { Lang } from "../content/config";
type Props = { type Props = {
instance?: string; lang: Lang;
user?: string; link: string;
postId?: string; instance: string;
user: string;
postId: string;
}; };
const { instance, user, postId } = Astro.props; const { link, instance, user, postId } = Astro.props;
--- ---
<section <section
id="comment-section" id="comment-section"
class="hidden px-2 font-serif" class="px-2 font-serif"
aria-describedby="title-comment-section" aria-describedby="title-comment-section"
data-instance={instance || ""} data-link={link}
data-user={user || ""} data-instance={instance}
data-post-id={postId || ""} 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"> <h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
Comments Comments
</h2> </h2>
<div class="text-stone-800 dark:text-stone-100" id="comments"> <div class="text-stone-800 dark:text-stone-100" id="comments">
<button <p class="my-1">
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" <a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>
id="load-comments-button" </p>
data-load-comments
>
<span>Click to load comments</span>
</button>
</div> </div>
</section> </section>
<template id="template-comments-loading"> <template id="template-button">
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24"> <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> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path <path
class="opacity-100" class="opacity-100"
@ -45,12 +55,16 @@ const { instance, user, postId } = Astro.props;
<template id="template-comment-box"> <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="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"> <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" /> <img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
<span data-display-name></span> <span data-display-name></span>
</a> </a>
<a data-post-link class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"> <a
<span class="mr-1" data-publish-date></span> 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> </a>
</div> </div>
<div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></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> </svg>
</div> </div>
</div> </div>
<div data-comment-thread class="-mb-2"></div> <div data-comment-thread class="-mb-2" aria-hidden></div>
</div> </div>
</template> </template>
<script> <script>
interface Post {
link: string;
instance: string;
user: string;
postId: string;
}
interface Emoji { interface Emoji {
shortcode: string; shortcode: string;
url: string; url: string;
@ -104,115 +125,131 @@ const { instance, user, postId } = Astro.props;
emojis: Emoji[]; emojis: Emoji[];
} }
interface ApiResponse {
ancestors: Comment[];
descendants: Comment[];
}
(function () { (function () {
const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) => function replaceEmojis(text: string, emojis: Emoji[]) {
emojis.reduce( return emojis.reduce(
(acc, emoji) => (acc, emoji) =>
acc.replaceAll( acc.replaceAll(
`:${emoji.shortcode}:`, `:${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, text,
); );
}
const commentSection = document.querySelector<Element>("#comment-section")!; async function renderComments(section: Element, post: Post) {
const instance = commentSection.getAttribute("data-instance"); const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
const user = commentSection.getAttribute("data-user"); try {
const postId = commentSection.getAttribute("data-post-id"); const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
if (instance && user && postId) { if (!response.ok) {
commentSection.classList.remove("hidden"); throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
commentSection.querySelector<HTMLButtonElement>("button[data-load-comments]")!.addEventListener("click", (e) => { }
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(); e.preventDefault();
const loadCommentsButton = e.target as HTMLButtonElement;
loadCommentsButton.setAttribute("disabled", "true"); loadCommentsButton.setAttribute("disabled", "true");
loadCommentsButton.replaceChildren( 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 () => { renderComments(commentSection, post as Post);
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();
}); });
} }
initCommentSection();
})(); })();
</script> </script>

View file

@ -1,5 +1,5 @@
--- ---
import { type Lang } from "../content/config"; import type { Lang } from "../content/config";
import { t } from "../i18n"; import { t } from "../i18n";
type Props = { 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
}

View file

@ -1,6 +1,6 @@
--- ---
import { type CollectionEntry } from "astro:content"; import type { CollectionEntry } from "astro:content";
import { type Lang } from "../content/config"; import type { Lang } from "../content/config";
import { getUsernameForLang } from "../utils/get_username_for_lang"; import { getUsernameForLang } from "../utils/get_username_for_lang";
type Props = { type Props = {
@ -12,12 +12,7 @@ let { user, lang } = Astro.props;
const username = getUsernameForLang(user, lang); const username = getUsernameForLang(user, lang);
let link: string | null = null; let link: string | null = null;
if (user.data.preferredLink) { if (user.data.preferredLink) {
const preferredLink = user.data.links[user.data.preferredLink]!; link = user.data.links[user.data.preferredLink]!.link;
if (typeof preferredLink === "string") {
link = preferredLink;
} else {
link = preferredLink[0];
}
} }
--- ---

View file

@ -1 +0,0 @@
../assets/LICENSE

View file

@ -2,89 +2,214 @@ import { defineCollection, reference, z } from "astro:content";
// Constants // Constants
export const WEBSITE_LIST = [ export const DEFAULT_LANG = "en";
"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 ANONYMOUS_USER_ID = "anonymous"; 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 // Transformers
const trimText = (text: string) => text.trim(); function parseRegex<R extends { [key: string]: string }>(regex: RegExp) {
const adjustDateForUTCOffset = (date: Date) => return (url: string, ctx: z.RefinementCtx) => {
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0); const match = regex.exec(url);
const parseMastodonPostUrl = (url: string, ctx: z.RefinementCtx) => { if (!match?.groups) {
const match = mastodonPostUrlRegex.exec(url); ctx.addIssue({
if (!match) { code: z.ZodIssueCode.custom,
ctx.addIssue({ message: `"${ctx.path}" did not match regex`,
code: z.ZodIssueCode.custom, });
message: `"mastodon" post contains an invalid URL`, return z.NEVER;
}); }
return z.NEVER; return match.groups as R;
}
return {
instance: match[1]!,
user: match[2]!,
postId: match[3]!,
}; };
}; }
// 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 // 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 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 GamePlatform = z.infer<typeof platform>;
export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>; export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
@ -93,79 +218,52 @@ export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
const storiesCollection = defineCollection({ const storiesCollection = defineCollection({
type: "content", type: "content",
schema: ({ image }) => schema: ({ image }) =>
z.object({ z
// Required .object({
title: z.string(), // Required parameters, but optional for drafts (isDraft == true)
wordCount: z.number().int().optional(), wordCount: z.number().int().optional(),
contentWarning: z.string().transform(trimText), thumbnail: image().optional(),
description: z.string().transform(trimText), // Optional parameters
tags: z.array(z.string()), shortTitle: z.string().optional(),
// Optional commissioner: userList.optional(),
pubDate: z.date().transform(adjustDateForUTCOffset).optional(), requester: userList.optional(),
isDraft: z.boolean().default(false), summary: z.string().trim().optional(),
shortTitle: z.string().optional(), thumbnailWidth: z.number().int().optional(),
authors, thumbnailHeight: z.number().int().optional(),
summary: z.string().transform(trimText).optional(), prev: reference("stories").nullish(),
thumbnail: image().optional(), next: reference("stories").nullish(),
thumbnailWidth: z.number().int().optional(), })
thumbnailHeight: z.number().int().optional(), .and(publishedContent)
series: reference("series").optional(), .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published story`)
commissioner: userList.optional(), .refine(
requester: userList.optional(), ({ isDraft, contentWarning }) => isDraft || contentWarning,
copyrightedCharacters: copyrightedCharacters, `Missing "contentWarning" for published story`,
lang, )
prev: reference("stories").nullish(), .refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`)
next: reference("stories").nullish(), .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published story`)
relatedStories: z.array(reference("stories")).default([]), .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`)
relatedGames: z.array(reference("games")).default([]), .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published story`),
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({}),
}),
}); });
const gamesCollection = defineCollection({ const gamesCollection = defineCollection({
type: "content", type: "content",
schema: ({ image }) => schema: ({ image }) =>
z.object({ z
// Required .object({
title: z.string(), // Required parameters, but optional for drafts (isDraft == true)
contentWarning: z.string().transform(trimText), platforms: z.array(platform).default([]),
description: z.string().transform(trimText), thumbnail: image().optional(),
platforms: z.array(platform).refine((platforms) => platforms.length > 0, `"platforms" cannot be empty`), // Optional parameters
tags: z.array(z.string()), thumbnailWidth: z.number().int().optional(),
// Optional thumbnailHeight: z.number().int().optional(),
pubDate: z.date().transform(adjustDateForUTCOffset).optional(), })
isDraft: z.boolean().default(false), .and(publishedContent)
authors, .refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published game`)
thumbnail: image().optional(), .refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`)
thumbnailWidth: z.number().int().optional(), .refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`)
thumbnailHeight: z.number().int().optional(), .refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published game`)
series: reference("series").optional(), .refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`)
copyrightedCharacters: copyrightedCharacters, .refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published game`),
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({}),
}),
}); });
// Data collections // Data collections
@ -175,12 +273,11 @@ const usersCollection = defineCollection({
schema: ({ image }) => schema: ({ image }) =>
z z
.object({ .object({
// Required // Required parameters
name: z.string(), name: langRecord.or(z.string()),
links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])), links: websiteLinks.partial(),
// Optional // Optional parameters
preferredLink: website.nullish(), preferredLink: websiteLinks.keyof().nullish(),
lang: langRecord.optional(),
avatar: image().optional(), avatar: image().optional(),
}) })
.refine( .refine(
@ -195,7 +292,7 @@ const usersCollection = defineCollection({
const seriesCollection = defineCollection({ const seriesCollection = defineCollection({
type: "data", type: "data",
schema: z.object({ schema: z.object({
// Required // Required parameters
name: z.string(), name: z.string(),
url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`), url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
}), }),
@ -204,16 +301,18 @@ const seriesCollection = defineCollection({
const tagCategoriesCollection = defineCollection({ const tagCategoriesCollection = defineCollection({
type: "data", type: "data",
schema: z.object({ schema: z.object({
// Required // Required parameters
name: z.string(), name: z.string(),
index: z.number().int(), index: z.number().int(),
tags: z.array( tags: z
z.object({ .array(
name: z.union([z.string(), langRecord]), z.object({
description: z.string().optional(), name: langRecord.or(z.string()),
related: z.array(z.string()).optional(), description: z.string().trim().optional(),
}), related: z.array(z.string()).default([]),
), }),
)
.refine((tags) => tags.length, `"tags" cannot be empty`),
}), }),
}); });

View file

@ -28,6 +28,7 @@ posts:
inkbunny: https://inkbunny.net/s/3262911 inkbunny: https://inkbunny.net/s/3262911
sofurry: https://www.sofurry.com/view/2109688 sofurry: https://www.sofurry.com/view/2109688
weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game 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 mastodon: https://meow.social/@BadManners/112009918919441027
tags: tags:
- oral vore - oral vore

View file

@ -2,7 +2,6 @@
slug: playing-it-safe slug: playing-it-safe
title: Playing It Safe title: Playing It Safe
pubDate: 2024-08-08 pubDate: 2024-08-08
isDraft: true
authors: bad-manners authors: bad-manners
wordCount: 9900 wordCount: 9900
contentWarning: > contentWarning: >

View file

@ -16,6 +16,7 @@ posts:
inkbunny: https://inkbunny.net/s/3283508 inkbunny: https://inkbunny.net/s/3283508
sofurry: https://www.sofurry.com/view/2118138 sofurry: https://www.sofurry.com/view/2118138
weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident 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 mastodon: https://meow.social/@BadManners/112157812554023271
tags: tags:
- anthro predator - anthro predator

View file

@ -1,7 +1,7 @@
name: Types of vore name: Types of vore
index: 1 index: 1
tags: 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. description: Scenarios where prey are consumed by the predator through their mouth.
- name: anal vore - name: anal vore
description: Scenarios where prey are consumed by the predator through their butt/anus. description: Scenarios where prey are consumed by the predator through their butt/anus.

View file

@ -7,7 +7,7 @@ tags:
description: Scenarios where at least one of the predators is an animal based on a real or mythological creature. description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
- name: taur predator - 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. 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. description: Scenarios where the body type of at least one of the predators is left ambiguous.
- name: human prey - name: human prey
description: Scenarios where at least one of the prey is a human person. 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". description: Scenarios where at least one of the prey is an anthropomorphic animal, i.e. generally regarded as a "furry".
- name: feral prey - name: feral prey
description: Scenarios where at least one of the predators is an animal based on a real or mythological creature. 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. description: Scenarios where the body type of at least one of the predators is left ambiguous.

View file

@ -13,7 +13,7 @@ tags:
description: Scenarios where at least one of the predators is a woman and/or female-presenting. description: Scenarios where at least one of the predators is a woman and/or female-presenting.
- name: non-binary predator - 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. 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. description: Scenarios where the gender at least one of the predators is left ambiguous.
- name: male prey - name: male prey
description: Scenarios where at least one of the prey is a man and/or male-presenting. description: Scenarios where at least one of the prey is a man and/or male-presenting.
@ -27,5 +27,5 @@ tags:
- female prey - female prey
- name: non-binary 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. 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. description: Scenarios where the gender at least one of the predators is left ambiguous.

View file

@ -1,7 +1,7 @@
name: Willingness name: Willingness
index: 5 index: 5
tags: 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. description: Scenarios where at least one of the predators participates in vore willingly.
- name: semi-willing predator - 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. 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. description: Scenarios where at least one of the prey participates in vore willingly.
- name: semi-willing prey - 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. 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. description: Scenarios where at least one of the prey participates in vore unwillingly.
- name: asleep prey - name: asleep prey
description: Scenarios where at least one of the predators participates in vore while asleep. description: Scenarios where at least one of the predators participates in vore while asleep.

View file

@ -66,4 +66,4 @@ tags:
- name: soul vore - name: soul vore
description: Scenarios where predators consume a soul instead of their prey's body. description: Scenarios where predators consume a soul instead of their prey's body.
- name: Vore Day - 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.

View file

@ -5,7 +5,7 @@ tags:
description: Stories made by someone else's request, as a gift. description: Stories made by someone else's request, as a gift.
- name: commission - name: commission
description: Stories made as part of a commission to someone else. 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. description: Short-format stories of no more than 2,500 words.
- name: toki pona - name: toki pona
description: Stories written in toki pona, the language of good. description: Stories written in toki pona, the language of good.

View file

@ -1,6 +1,5 @@
name: Anonymous name:
lang: en: anonymous
eng: anonymous
tok: jan pi nimi ala tok: jan pi nimi ala
links: {} links: {}
preferredLink: ~ preferredLink: ~

View file

@ -1,7 +1,7 @@
name: Asof Yeun name: Asof Yeun
links: links:
eka: https://aryion.com/g4/user/asofyeun 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 inkbunny: https://inkbunny.net/asofyeun
sofurry: https://asofyeun.sofurry.com/ sofurry: https://asofyeun.sofurry.com/
weasyl: https://www.weasyl.com/~asofyeun weasyl: https://www.weasyl.com/~asofyeun

View file

@ -1,6 +1,5 @@
name: Bad Manners name:
lang: en: Bad Manners
eng: Bad Manners
tok: nasin ike Pemene tok: nasin ike Pemene
avatar: /src/assets/images/logo_bm.png avatar: /src/assets/images/logo_bm.png
links: links:
@ -9,8 +8,8 @@ links:
furaffinity: https://www.furaffinity.net/user/BadManners furaffinity: https://www.furaffinity.net/user/BadManners
inkbunny: https://inkbunny.net/BadManners inkbunny: https://inkbunny.net/BadManners
sofurry: sofurry:
- https://bad-manners.sofurry.com/ link: https://bad-manners.sofurry.com/
- Bad Manners username: Bad Manners
weasyl: https://www.weasyl.com/~BadManners weasyl: https://www.weasyl.com/~BadManners
twitter: https://twitter.com/BadManners__ twitter: https://twitter.com/BadManners__
mastodon: https://meow.social/@BadManners mastodon: https://meow.social/@BadManners

View file

@ -1,6 +1,6 @@
name: Dr. Hans Woofington name: Dr. Hans Woofington
links: links:
furaffinity: furaffinity:
- https://www.furaffinity.net/user/hanslewdington/ link: https://www.furaffinity.net/user/hanslewdington/
- Hans_Lewdington username: Hans_Lewdington
preferredLink: furaffinity preferredLink: furaffinity

View file

@ -2,6 +2,6 @@ name: YolkMonkey
links: links:
furaffinity: https://furaffinity.net/user/Vampire101 furaffinity: https://furaffinity.net/user/Vampire101
sofurry: sofurry:
- https://vampire101.sofurry.com/ link: https://vampire101.sofurry.com/
- Vampire101 username: Vampire101
preferredLink: furaffinity preferredLink: furaffinity

View file

@ -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"; import { DEFAULT_LANG } from "../content/config";
export { DEFAULT_LANG } from "../content/config"; export { DEFAULT_LANG } from "../content/config";
const UI_STRINGS = { const UI_STRINGS = {
// Utility functions
"util/join_names": { "util/join_names": {
eng: (names: string[]) => en: (names: string[]) =>
names.length <= 1 names.length <= 1
? names.join("") ? names.join("")
: names.length == 2 : names.length == 2
@ -13,10 +14,10 @@ const UI_STRINGS = {
tok: (names: string[]) => names.join(" en "), tok: (names: string[]) => names.join(" en "),
}, },
"util/capitalize": { "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": { "util/enumerate": {
eng: (count: number, nounSingular: string, nounPlural: string | undefined) => { en: (count: number, nounSingular: string, nounPlural?: string) => {
if (count == 0) { if (count == 0) {
return `no ${nounPlural ?? nounSingular}`; return `no ${nounPlural ?? nounSingular}`;
} }
@ -25,146 +26,205 @@ const UI_STRINGS = {
} }
return `${count} ${nounPlural ?? nounSingular}`; 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"}`, `${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`,
}, },
"export_story/writing": { // export-story API functions
eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`, "export_story/authors": {
en: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`, tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`,
}, },
"export_story/request_for": { "export_story/request_for": {
eng: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`, en: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`,
}, },
"export_story/commissioned_by": { "export_story/commissioned_by": {
eng: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`, en: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`,
}, },
"story/return_to_stories": { // Shared strings for published content (stories + games)
eng: "Return to stories", "published_content/return_to_series": {
tok: "o tawa e lipu ale", en: (seriesName: string) => `Return to ${seriesName}`,
}, },
"story/return_to_series": { "published_content/go_to_description": {
eng: (seriesName: string) => `Return to ${seriesName}`, en: "Go to description",
},
"story/go_to_description": {
eng: "Go to description",
tok: "o tawa e toki lipu", tok: "o tawa e toki lipu",
}, },
"story/toggle_dark_mode": { "published_content/toggle_dark_mode": {
eng: "Toggle dark mode", en: "Toggle dark mode",
tok: "o ante e kule lipu", 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": { "story/warnings": {
eng: (wordCount: number | string | undefined, contentWarning: string) => en: (wordCount: number | string | undefined, contentWarning: string) =>
wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning, wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning,
tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning, tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning,
}, },
"story/publish_date": { "story/article_aria_label": {
eng: (date: string) => date, en: "Story",
tok: (date: string) => `tenpo suno ${date}`, tok: "lipu",
},
"story/description": {
eng: "Description",
tok: "toki lipu",
}, },
"story/summary": { "story/summary": {
eng: "Summary", en: "Summary",
tok: "lipu tawa tenpo lili", tok: "lipu tawa tenpo lili",
}, },
"story/reveal_summary": { "story/reveal_summary": {
eng: "Click to reveal", en: "Click to reveal",
tok: "Click to reveal summary in English", 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": { "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[]) => tok: (authorsList: string[]) =>
authorsList.length > 1 authorsList.length > 1
? `lipu ni li tan jan ni: ${UI_STRINGS["util/join_names"].tok(authorsList)}` ? `lipu ni li tan jan ni: ${UI_STRINGS["util/join_names"].tok(authorsList)}`
: `lipu ni li tan ${authorsList[0]}`, : `lipu ni li tan ${authorsList[0]}`,
}, },
"story/commissioned_by": { "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": { "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": { // Game page-specific strings
eng: "DRAFT VERSION DO NOT REDISTRIBUTE", "game/return_to_games": {
en: "Return to games",
}, },
"characters/characters_are_copyrighted_by": { "game/title_aria_label": {
eng: (owner: string, charactersList: string[]) => en: "Game title",
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/platforms": { "game/platforms": {
eng: (platforms: GamePlatform[]) => { en: (platforms: GamePlatform[]) => {
if (platforms.length == 0) { if (platforms.length == 0) {
return ""; return "";
} }
const translatedPlatforms = platforms.map((platform) => { const translatedPlatforms = platforms.map((platform) => {
const platformLang = UI_STRINGS[`game/platform_${platform}`].eng; const platformLang = UI_STRINGS[`game/platform_${platform}`].en;
if (!platformLang) { if (!platformLang) {
throw new Error(`Invalid platform "${platform}"`); throw new Error(`Invalid platform "${platform}"`);
} }
return platformLang; 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": { "game/platform_web": {
eng: "web browsers", en: "web browsers",
}, },
"game/platform_windows": { "game/platform_windows": {
eng: "Windows", en: "Windows",
}, },
"game/platform_linux": { "game/platform_linux": {
eng: "Linux", en: "Linux",
}, },
"game/platform_macos": { "game/platform_macos": {
eng: "macOS", en: "macOS",
}, },
"game/platform_android": { "game/platform_android": {
eng: "Android", en: "Android",
}, },
"game/platform_ios": { "game/platform_ios": {
eng: "iOS", en: "iOS",
}, },
"game/warnings": { "game/warnings": {
eng: (platforms: GamePlatform[], contentWarning: string) => en: (platforms: GamePlatform[], contentWarning: string) =>
platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)} ${contentWarning}` : contentWarning, 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": { "tag/total_works_with_tag": {
eng: (tag: string, storiesCount: number, gamesCount: number) => { en: (tag: string, storiesCount: number, gamesCount: number) => {
const content = []; const content = [];
if (storiesCount > 0) { 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) { 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) { if (content.length == 0) {
return `No works tagged with "${tag}".`; 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; } as const;
@ -176,6 +236,9 @@ type TranslationEntry<T> = { [DEFAULT_LANG]: T } & {
type TranslationArgs<K extends TranslationKey> = type TranslationArgs<K extends TranslationKey> =
(typeof UI_STRINGS)[K] extends TranslationEntry<infer T> ? (T extends (...args: infer A) => string ? A : []) : never; (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 { export function t<K extends TranslationKey>(lang: Lang, key: K, ...args: TranslationArgs<K>): string {
if (key in UI_STRINGS) { if (key in UI_STRINGS) {
const translation: string | ((...args: TranslationArgs<K>) => string) = const translation: string | ((...args: TranslationArgs<K>) => string) =

View file

@ -6,12 +6,13 @@ import AgeRestrictedModal from "../components/AgeRestrictedModal.astro";
type Props = { type Props = {
pageTitle?: string; pageTitle?: string;
lang?: string;
}; };
const { pageTitle } = Astro.props; const { pageTitle = "Gallery", lang = "en" } = Astro.props;
--- ---
<html lang="en"> <html lang={lang}>
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" /> <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="theme-color" content="#ffffff" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="generator" content={Astro.generator} /> <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="me" href="https://meow.social/@BadManners" />
<link <link
rel="alternate" rel="alternate"

View file

@ -18,8 +18,8 @@ const { props } = Astro;
const series = props.series && (await getEntry(props.series)); const series = props.series && (await getEntry(props.series));
const authorsList = await getEntries(props.authors); const authorsList = await getEntries(props.authors);
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters); const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
// const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.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 relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries( const categorizedTags = Object.fromEntries(
(await getCollection("tag-categories")).flatMap((category) => (await getCollection("tag-categories")).flatMap((category) =>
category.data.tags.map<[string, string | null]>(({ name }) => 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 })); (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"> <Fragment slot="head">
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" /> <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]" /> <meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
{ {
thumbnail ? ( thumbnail ? (
@ -55,7 +55,7 @@ const thumbnail =
<meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" /> <meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
<meta <meta
property="og:image:alt" 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]" data-pagefind-meta="image_alt[content]"
/> />
</Fragment> </Fragment>
@ -78,7 +78,9 @@ const thumbnail =
<a <a
href={series ? series.data.url : "/games"} href={series ? series.data.url : "/games"}
class="text-link my-1 h-9 w-9 p-2" 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"> <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path <path
@ -89,7 +91,7 @@ const thumbnail =
<a <a
href="#description" href="#description"
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" 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"> <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path <path
@ -100,7 +102,7 @@ const thumbnail =
<button <button
data-dark-mode data-dark-mode
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" 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"> <svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
<path <path
@ -120,7 +122,11 @@ const thumbnail =
data-pagefind-body={props.isDraft ? undefined : ""} data-pagefind-body={props.isDraft ? undefined : ""}
data-pagefind-meta="type:game" 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} {props.title}
</h1> </h1>
<section <section
@ -136,7 +142,7 @@ const thumbnail =
{ {
props.isDraft ? ( props.isDraft ? (
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600"> <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> </p>
) : null ) : null
} }
@ -155,7 +161,7 @@ const thumbnail =
<img <img
loading="eager" loading="eager"
src={thumbnail.src} src={thumbnail.src}
alt={`Cover art for ${props.title}`} alt={t(props.lang, "published_content/cover_art_alt", props.title)}
width={props.thumbnailWidth} width={props.thumbnailWidth}
height={props.thumbnailHeight} height={props.thumbnailHeight}
class="mx-auto my-5 shadow-lg" class="mx-auto my-5 shadow-lg"
@ -165,7 +171,7 @@ const thumbnail =
) : null ) : null
} }
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> <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> <Prose>
<slot /> <slot />
</Prose> </Prose>
@ -177,28 +183,26 @@ const thumbnail =
id="draft-warning-bottom" id="draft-warning-bottom"
class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600" 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> </p>
) : props.pubDate ? ( ) : props.pubDate ? (
<p <p
id="publish-date" id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200" class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
aria-label="Publish date" aria-label={t(props.lang, "published_content/publish_date_aria_label")}
aria-description={props.pubDate.toLocaleDateString("en-US", { aria-description={
month: "long", t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
day: "numeric", }
year: "numeric",
})}
data-pagefind-index-attrs="aria-description" data-pagefind-index-attrs="aria-description"
data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`} 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> </p>
) : null ) : null
} }
<section id="description" class="px-2 font-serif" aria-describedby="title-description"> <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"> <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> </h2>
<Prose> <Prose>
<Markdown of={props.description} /> <Markdown of={props.description} />
@ -211,14 +215,50 @@ const thumbnail =
><path ><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" 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 ></path></svg
><span>{t(props.lang, "story/to_top")}</span></a ><span>{t(props.lang, "published_content/to_top")}</span></a
> >
</div> </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 ? ( tags.length > 0 ? (
<section id="tags" aria-describedby="title-tags" class="my-5"> <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"> <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> </h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2"> <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{tags.map(({ id, name }) => ( {tags.map(({ id, name }) => (
@ -232,16 +272,12 @@ const thumbnail =
</section> </section>
) : null ) : null
} }
<MastodonComments {props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
instance={props.posts.mastodon?.instance}
user={props.posts.mastodon?.user}
postId={props.posts.mastodon?.postId}
/>
</main> </main>
<div class="pt-6 text-center text-xs text-black dark:text-white"> <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" <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>
</div> </div>

View file

@ -17,21 +17,15 @@ import { formatCopyrightedCharacters } from "../utils/format_copyrighted_charact
type Props = CollectionEntry<"stories">["data"]; type Props = CollectionEntry<"stories">["data"];
const { props } = Astro; const { props } = Astro;
let prev = props.prev && (await getEntry(props.prev)); const prev = props.prev && (await getEntry(props.prev));
if (prev && prev.data.isDraft) { const next = props.next && (await getEntry(props.next));
prev = undefined;
}
let next = props.next && (await getEntry(props.next));
if (next && next.data.isDraft) {
next = undefined;
}
const series = props.series && (await getEntry(props.series)); const series = props.series && (await getEntry(props.series));
const authorsList = await getEntries(props.authors); const authorsList = await getEntries(props.authors);
const commissionersList = props.commissioner && (await getEntries(props.commissioner)); const commissionersList = props.commissioner && (await getEntries(props.commissioner));
const requestersList = props.requester && (await getEntries(props.requester)); const requestersList = props.requester && (await getEntries(props.requester));
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters); const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.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 relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries( const categorizedTags = Object.fromEntries(
(await getCollection("tag-categories")).flatMap((category) => (await getCollection("tag-categories")).flatMap((category) =>
category.data.tags.map<[string, string | null]>(({ name }) => category.data.tags.map<[string, string | null]>(({ name }) =>
@ -57,7 +51,7 @@ const thumbnail =
const wordCount = props.wordCount?.toString(); const wordCount = props.wordCount?.toString();
--- ---
<BaseLayout pageTitle={props.title}> <BaseLayout pageTitle={props.title} lang={props.lang}>
<Fragment slot="head"> <Fragment slot="head">
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" /> <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)} /> <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 content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
<meta <meta
property="og:image:alt" 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]" data-pagefind-meta="image_alt[content]"
/> />
</Fragment> </Fragment>
@ -92,7 +86,7 @@ const wordCount = props.wordCount?.toString();
href={series ? series.data.url : "/stories/1"} href={series ? series.data.url : "/stories/1"}
class="text-link my-1 h-9 w-9 p-2" class="text-link my-1 h-9 w-9 p-2"
aria-label={series 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")} : t(props.lang, "story/return_to_stories")}
> >
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true"> <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
@ -104,7 +98,7 @@ const wordCount = props.wordCount?.toString();
<a <a
href="#description" href="#description"
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" 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"> <svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path <path
@ -115,7 +109,7 @@ const wordCount = props.wordCount?.toString();
<button <button
data-dark-mode data-dark-mode
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700" 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"> <svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
<path <path
@ -136,7 +130,7 @@ const wordCount = props.wordCount?.toString();
data-pagefind-meta="type:story" data-pagefind-meta="type:story"
> >
{ {
prev || next ? ( (prev && !prev.data.isDraft) || (next && !next.data.isDraft) ? (
<div class="print:hidden"> <div class="print:hidden">
<div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2"> <div id="story-nav-top" class="my-4 grid grid-cols-2 justify-items-stretch gap-2">
{prev ? ( {prev ? (
@ -170,7 +164,11 @@ const wordCount = props.wordCount?.toString();
</div> </div>
) : null ) : 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} {props.title}
</h1> </h1>
<section <section
@ -180,13 +178,6 @@ const wordCount = props.wordCount?.toString();
<Authors lang={props.lang}> <Authors lang={props.lang}>
{authorsList.map((author) => <UserComponent user={author} lang={props.lang} />)} {authorsList.map((author) => <UserComponent user={author} lang={props.lang} />)}
</Authors> </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 && ( requestersList && (
<Requesters lang={props.lang}> <Requesters lang={props.lang}>
@ -205,6 +196,13 @@ const wordCount = props.wordCount?.toString();
</Commissioners> </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"> <div id="content-warning">
<p> <p>
{t(props.lang, "story/warnings", wordCount, props.contentWarning)} {t(props.lang, "story/warnings", wordCount, props.contentWarning)}
@ -218,7 +216,7 @@ const wordCount = props.wordCount?.toString();
<img <img
loading="eager" loading="eager"
src={thumbnail.src} 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} width={props.thumbnailWidth}
height={props.thumbnailHeight} height={props.thumbnailHeight}
class="mx-auto my-5 shadow-lg" class="mx-auto my-5 shadow-lg"
@ -227,7 +225,7 @@ const wordCount = props.wordCount?.toString();
) : null ) : null
} }
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" /> <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> <Prose>
<slot /> <slot />
</Prose> </Prose>
@ -239,28 +237,26 @@ const wordCount = props.wordCount?.toString();
id="draft-warning-bottom" id="draft-warning-bottom"
class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600" 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> </p>
) : props.pubDate ? ( ) : props.pubDate ? (
<p <p
id="publish-date" id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200" class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
aria-label="Publish date" aria-label={t(props.lang, "published_content/publish_date_aria_label")}
aria-description={props.pubDate.toLocaleDateString("en-US", { aria-description={
month: "long", t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
day: "numeric", }
year: "numeric",
})}
data-pagefind-index-attrs="aria-description" data-pagefind-index-attrs="aria-description"
data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`} 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> </p>
) : null ) : null
} }
<section id="description" class="px-2 font-serif" aria-describedby="title-description"> <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"> <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> </h2>
<Prose> <Prose>
<Markdown of={props.description} /> <Markdown of={props.description} />
@ -292,7 +288,7 @@ const wordCount = props.wordCount?.toString();
><path ><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" 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 ></path></svg
><span>{t(props.lang, "story/to_top")}</span></a ><span>{t(props.lang, "published_content/to_top")}</span></a
> >
</div> </div>
{ {
@ -335,13 +331,31 @@ const wordCount = props.wordCount?.toString();
relatedStories.length > 0 ? ( relatedStories.length > 0 ? (
<section id="related" aria-describedby="title-related" class="my-5"> <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"> <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> </h2>
<Prose> <Prose>
<ul> <ul>
{relatedStories.map((stories) => ( {relatedStories.map((story) => (
<li> <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> </li>
))} ))}
</ul> </ul>
@ -353,7 +367,7 @@ const wordCount = props.wordCount?.toString();
tags.length > 0 ? ( tags.length > 0 ? (
<section id="tags" aria-describedby="title-tags" class="my-5"> <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"> <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> </h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2"> <ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{tags.map(({ id, name }) => ( {tags.map(({ id, name }) => (
@ -367,16 +381,12 @@ const wordCount = props.wordCount?.toString();
</section> </section>
) : null ) : null
} }
<MastodonComments {props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
instance={props.posts.mastodon?.instance}
user={props.posts.mastodon?.user}
postId={props.posts.mastodon?.postId}
/>
</main> </main>
<div class="pt-6 text-center text-xs text-black dark:text-white"> <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" <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>
</div> </div>

View file

@ -1,6 +1,6 @@
import type { APIRoute, GetStaticPaths } from "astro"; import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; import { getCollection, type CollectionEntry, getEntries } from "astro:content";
import type { Website } from "../../../content/config"; import type { Lang, Website } from "../../../content/config";
import { t } from "../../../i18n"; import { t } from "../../../i18n";
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters"; import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode"; 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 { function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
const link = user.data.links[website]; const link = user.data.links[website];
if (link) { if (link?.username) {
if (typeof link === "string") { return link.username;
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(/^@/, "");
}
} }
throw new Error(`Cannot get "${website}" username for user "${user.id}"`); 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; 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) { switch (website) {
case "eka": case "eka":
if ("eka" in user.data.links) { if ("eka" in links) {
return `:icon${getUsernameForWebsite(user, "eka")}:`; return `:icon${getUsernameForWebsite(user, "eka")}:`;
} }
break; break;
case "furaffinity": case "furaffinity":
if ("furaffinity" in user.data.links) { if ("furaffinity" in links) {
return `:icon${getUsernameForWebsite(user, "furaffinity")}:`; return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
} }
break; break;
case "weasyl": case "weasyl":
if ("weasyl" in user.data.links) { if ("weasyl" in links) {
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`; return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
} else if (isPreferredWebsite(user, "furaffinity")) { } else if (isPreferredWebsite(user, "furaffinity")) {
return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`; return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
@ -123,7 +60,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
} }
break; break;
case "inkbunny": case "inkbunny":
if ("inkbunny" in user.data.links) { if ("inkbunny" in links) {
return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`; return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
} else if (isPreferredWebsite(user, "furaffinity")) { } else if (isPreferredWebsite(user, "furaffinity")) {
return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`; return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
@ -134,7 +71,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
} }
break; break;
case "sofurry": case "sofurry":
if ("sofurry" in user.data.links) { if ("sofurry" in links) {
return `:icon${getUsernameForWebsite(user, "sofurry")}:`; return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
} else if (isPreferredWebsite(user, "furaffinity")) { } else if (isPreferredWebsite(user, "furaffinity")) {
return `fa!${getUsernameForWebsite(user, "furaffinity")}`; return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
@ -143,11 +80,12 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
} }
break; break;
default: default:
throw new Error(`Unhandled ExportWebsite "${website}"`); const unknown: never = website;
throw new Error(`Unhandled export website "${unknown}"`);
} }
if (user.data.preferredLink) { if (preferredLink) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string]; const preferred = links[preferredLink]!;
return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`; return `[${getUsernameForLang(user, lang)}](${preferred.link})`;
} }
throw new Error( throw new Error(
`No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`, `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( const description = Object.fromEntries(
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => { WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
const u = (user: CollectionEntry<"users">) => const u = (user: CollectionEntry<"users">) =>
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website); isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang);
const storyDescription = ( const storyDescription = (
[ [
story.data.description, story.data.description,
`*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`, `*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`,
t( t(
lang, lang,
"export_story/writing", "export_story/authors",
authorsList.map((author) => u(author)), authorsList.map((author) => u(author)),
), ),
requestersList && requestersList &&
@ -216,12 +154,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
/\[([^\]]+)\]\((\/[^\)]+)\)/g, /\[([^\]]+)\]\((\/[^\)]+)\)/g,
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`, (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
); );
if (exportFormat === "bbcode") { switch (exportFormat) {
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")]; case "bbcode":
} else if (exportFormat === "markdown") { return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()]; case "markdown":
} else { return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
throw new Error(`Unhandled export format "${exportFormat}"`); 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") .replaceAll(/\n\n\n+/g, "\n\n")
.trim(); .trim();
const headers = { "Content-Type": "application/json; charset=utf-8" };
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
story: storyText, story: storyText,
description, description,
thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null, thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
}), }),
{ headers }, { headers: { "Content-Type": "application/json; charset=utf-8" } },
); );
}; };

View file

@ -1,12 +1,10 @@
import type { APIRoute } from "astro"; import type { APIRoute } from "astro";
const content = { isAlive: true };
const headers = { "Content-Type": "application/json; charset=utf-8" };
export const GET: APIRoute = () => { export const GET: APIRoute = () => {
if (import.meta.env.PROD) { if (import.meta.env.PROD) {
return new Response(null, { status: 404 }); 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" },
});
}; };

View file

@ -24,8 +24,7 @@ function toNoonUTCDate(date: Date) {
const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => { const getLinkForUser = (user: CollectionEntry<"users">, lang: Lang) => {
const userName = getUsernameForLang(user, lang); const userName = getUsernameForLang(user, lang);
if (user.data.preferredLink) { if (user.data.preferredLink) {
const link = user.data.links[user.data.preferredLink]!; return `<a href="${user.data.links[user.data.preferredLink]!.link}">${userName}</a>`;
return `<a href="${typeof link === "string" ? link : link[0]}">${userName}</a>`;
} }
return userName; return userName;
}; };

View file

@ -18,17 +18,6 @@ export const getStaticPaths: GetStaticPaths = async () => {
}; };
const game = Astro.props; 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(); const { Content } = await game.render();
--- ---

View file

@ -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 { Image } from "astro:assets";
import GalleryLayout from "../layouts/GalleryLayout.astro"; import GalleryLayout from "../layouts/GalleryLayout.astro";
import { t } from "../i18n"; import { t } from "../i18n";

View file

@ -20,21 +20,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
const story = Astro.props; const story = Astro.props;
const readingTime = getReadingTime(story.body); const readingTime = getReadingTime(story.body);
if (!story.data.isDraft) { if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 150) {
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) {
console.warn( console.warn(
`"wordCount" differs greatly from actual word count for published story ${story.data.title} ("${story.slug}") ` + `"wordCount" differs greatly from actual word count for published story ${story.data.title} ("${story.slug}") ` +
`(expected ~${story.data.wordCount}, found ${readingTime.words})`, `(expected ~${story.data.wordCount}, found ${readingTime.words})`,

View file

@ -10,65 +10,53 @@ interface Tag {
description?: string; description?: string;
} }
const [stories, games, tagCategories] = await Promise.all([ const [stories, games, tagCategories, seriesCollection] = await Promise.all([
getCollection("stories"), getCollection("stories"),
getCollection("games"), getCollection("games"),
getCollection("tag-categories"), getCollection("tag-categories"),
getCollection("series"),
]); ]);
const tagsSet = new Set<string>(); const uncategorizedTagsSet = new Set<string>();
const draftOnlyTagsSet = new Set<string>(); const draftOnlyTagsSet = new Set<string>();
const seriesCollection = await getCollection("series"); [stories, games].flat().forEach(({ data: { isDraft, tags } }) => {
// Add tags from non-drafts to set; then, add tags only from drafts to separate set if (isDraft) {
[stories, games] tags.forEach((tag) => draftOnlyTagsSet.add(tag));
.flat() } else {
.sort((a, b) => (a.data.isDraft ? 1 : b.data.isDraft ? -1 : 0)) tags.forEach((tag) => uncategorizedTagsSet.add(tag));
.forEach((value) => { }
if (value.data.isDraft) { });
value.data.tags.forEach((tag) => { // Tags from published content shouldn't be included in drafts-only set
if (!tagsSet.has(tag)) { uncategorizedTagsSet.forEach((tag) => draftOnlyTagsSet.delete(tag));
draftOnlyTagsSet.add(tag);
}
});
} else {
value.data.tags.forEach((tag) => {
tagsSet.add(tag);
});
}
});
const uncategorizedTagsSet = new Set(tagsSet); const uniqueSlugs = new Set<string>();
const categorizedTags = tagCategories const categorizedTags = tagCategories
.sort((a, b) => { .sort((a, b) => {
if (a.data.index == b.data.index) { if (a.data.index == b.data.index) {
throw new Error( throw new Error(`Found tag categories with same index value ${a.data.index} ("${a.id}", "${b.id}")`);
`Found tag categories with same index value ${a.data.index} ("${a.data.name}", "${b.data.name}")`,
);
} }
return a.data.index - b.data.index; return a.data.index - b.data.index;
}) })
.map((category) => { .map((category) => {
const tagList = category.data.tags.map<Tag>(({ name, description }) => { const tagList = category.data.tags.map<Tag>(({ name, description }) => {
description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " "); description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ");
const tag = typeof name === "string" ? name : name["eng"]; const tag = typeof name === "string" ? name : name.en;
const id = slug(tag); return { id: slug(tag), name: tag, description };
return { id, name: tag, description };
}); });
tagList.forEach(({ name }, index) => { tagList.forEach(({ id, name }) => {
if (index !== tagList.findLastIndex(({ name: otherTag }) => name == otherTag)) { if (uniqueSlugs.has(id)) {
throw new Error(`Duplicated tag "${name}" found in multiple tag-categories lists`); throw new Error(`Duplicated tag "${name}" found in multiple tag-categories entries`);
} }
uniqueSlugs.add(id);
}); });
return { return {
name: category.data.name, name: category.data.name,
id: category.id, id: slug(category.data.name),
tags: tagList.filter(({ name }) => { tags: tagList.filter(({ name }) => {
if (draftOnlyTagsSet.has(name)) { if (draftOnlyTagsSet.has(name)) {
console.log(`Omitting draft-only tag "${name}"`); // console.log(`Omitting draft-only tag "${name}"`);
return false; return false;
} }
if (tagsSet.has(name)) { uncategorizedTagsSet.delete(name);
uncategorizedTagsSet.delete(name);
}
return true; return true;
}), }),
}; };

View file

@ -1,7 +1,7 @@
--- ---
import type { GetStaticPaths } from "astro"; import type { GetStaticPaths } from "astro";
import { Image } from "astro:assets"; 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 { Markdown } from "@astropub/md";
import { slug } from "github-slugger"; import { slug } from "github-slugger";
import GalleryLayout from "../../layouts/GalleryLayout.astro"; import GalleryLayout from "../../layouts/GalleryLayout.astro";
@ -45,24 +45,22 @@ export const getStaticPaths: GetStaticPaths = async () => {
const tagDescriptions = tagCategories.reduce( const tagDescriptions = tagCategories.reduce(
(acc, category) => { (acc, category) => {
category.data.tags.forEach(({ name, description, related }) => { category.data.tags.forEach(({ name, description, related }) => {
if (related) { related = related.filter((relatedTag) => {
related = related.filter((relatedTag) => { if (relatedTag == name) {
if (relatedTag == name) { console.warn(`Tag "${name}" should not have itself as a related tag; removing`);
console.warn(`Tag "${name}" should not have itself as a related tag; removing`); return false;
return false; }
} if (!tags.has(relatedTag)) {
if (!tags.has(relatedTag)) { console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`);
console.warn(`Tag "${name}" has an unknown related tag "${relatedTag}"; removing`); return false;
return false; }
} return true;
return true; });
}); const key = typeof name === "string" ? name : name.en;
}
const key = typeof name === "string" ? name : name["eng"];
if (key in acc) { if (key in acc) {
throw new Error(`Duplicated tag "${key}" found in multiple tag-categories lists`); 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; return acc;
}, },

View file

@ -2,14 +2,15 @@ import type { CollectionEntry } from "astro:content";
import { DEFAULT_LANG, type Lang } from "../content/config"; import { DEFAULT_LANG, type Lang } from "../content/config";
export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string { export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
if (user.data.lang) { const { name } = user.data;
if (user.data.lang[lang]) { if (typeof name === "object") {
return user.data.lang[lang]; 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) { 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;
} }

View file

@ -1,6 +1,6 @@
import type { CollectionEntry } from "astro:content"; 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;

View file

@ -1,3 +1,4 @@
{ {
"extends": "astro/tsconfigs/strict" "extends": "astro/tsconfigs/strict",
"exclude": ["dist"]
} }