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": {
"*.css": "tailwindcss"
},
"json.schemas": [
{
"fileMatch": ["/src/content/series/**"],
"url": "./.astro/collections/series.schema.json"
},
{
"fileMatch": ["/src/content/tag-categories/**"],
"url": "./.astro/collections/tag-categories.schema.json"
},
{
"fileMatch": ["/src/content/users/**"],
"url": "./.astro/collections/users.schema.json"
}
],
"yaml.schemas": {
"./.astro/collections/series.schema.json": "/src/content/series/**",
"./.astro/collections/tag-categories.schema.json": "/src/content/tag-categories/**",
"./.astro/collections/users.schema.json": "/src/content/users/**"
},
"[astro]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},

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
- Node.js 20+
- (optional) LFTP, for the remote deployment script.
- (optional) rsync, for remote deployment.
- (optional) LibreOffice, for the story export script.
## Development
@ -31,7 +31,7 @@ npm run prettier # Prettier formatting
Requires `libreoffice` to be installed and in your path.
```bash
npm run export-story -- --output-dir ~/Documents/TO_UPLOAD slug-for-story-to-export
npm run export-story -- -o ~/Documents/TO_UPLOAD slug-for-story-to-export
```
### Build and deploy to remote
@ -40,19 +40,8 @@ npm run export-story -- --output-dir ~/Documents/TO_UPLOAD slug-for-story-to-exp
npm run build
```
Then, if you're using LFTP:
1. Create a new `.env` file at the root of the project with your credentials (SSH, SFTP, WebDav, etc.) if you haven't already:
```env
DEPLOY_LFTP_HOST=https://example-webdav-server.com
DEPLOY_LFTP_USER=example_user
DEPLOY_LFTP_PASSWORD=sup3r_s3cr3t_password
DEPLOY_LFTP_TARGETFOLDER=sites/gallery.badmanners.xyz/
```
2. Run the deploy command:
Then, after configuring the `gallerybm` host (or the name of your choosing) in `~/.ssh/config`:
```bash
npm run deploy-lftp
rsync --delete -acP dist/ gallerybm:/home/public
```

View file

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

View file

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

View file

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

View file

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

307
package-lock.json generated
View file

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

View file

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

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 source code of this website is licensed under the MIT License. The content hosted on it (i.e. stories, games, and respective thumbnails/images) is copyrighted by me and distributed under the CC BY-NC-ND 4.0 license.
The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1: https://opensource.org/license/ofl-1-1
The Noto Sans and Noto Serif typefaces are copyrighted to the Noto Project Authors and distributed under the SIL Open Font License v1.1.
The generic SVG icons were created by Font Awesome and are distributed under the CC-BY-4.0 license.
The generic SVG icons were created by Font Awesome and are distributed under CC-BY-4.0: https://creativecommons.org/licenses/by/4.0/
All third-party trademarks and attributed characters belong to their respective owners, and I'm not affiliated with any of them.
`.trim();
const headers = { "Content-Type": "text/plain; charset=utf-8" };
export const GET: APIRoute = () => {
return new Response(licenses, { headers });
};

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

Binary file not shown.

Before

(image error) Size: 22 KiB

After

(image error) Size: 20 KiB

View file

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

View file

@ -1,5 +1,5 @@
---
import { type Lang } from "../content/config";
import type { Lang } from "../content/config";
import { t } from "../i18n";
type Props = {
@ -12,4 +12,12 @@ const authors = Astro.slots.has("default")
: [];
---
{authors.length ? <p id="authors" set:html={t(lang, "story/authors", authors)} /> : null}
{
authors.length ? (
<p
id="authors"
aria-label={t(lang, "story/authors_aria_label", authors)}
set:html={t(lang, "story/authors", authors)}
/>
) : null
}

View file

@ -1,5 +1,5 @@
---
import { type Lang } from "../content/config";
import type { Lang } from "../content/config";
import { t } from "../i18n";
type Props = {
@ -12,4 +12,12 @@ const commissioners = Astro.slots.has("default")
: [];
---
{commissioners.length ? <p id="commissioners" set:html={t(lang, "story/commissioned_by", commissioners)} /> : null}
{
commissioners.length ? (
<p
id="commissioners"
aria-label={t(lang, "story/commissioners_aria_label", commissioners)}
set:html={t(lang, "story/commissioned_by", commissioners)}
/>
) : null
}

View file

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

View file

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

View file

@ -1,37 +1,47 @@
---
import type { Lang } from "../content/config";
type Props = {
instance?: string;
user?: string;
postId?: string;
lang: Lang;
link: string;
instance: string;
user: string;
postId: string;
};
const { instance, user, postId } = Astro.props;
const { link, instance, user, postId } = Astro.props;
---
<section
id="comment-section"
class="hidden px-2 font-serif"
class="px-2 font-serif"
aria-describedby="title-comment-section"
data-instance={instance || ""}
data-user={user || ""}
data-post-id={postId || ""}
data-link={link}
data-instance={instance}
data-user={user}
data-post-id={postId}
>
<h2 id="title-comment-section" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
Comments
</h2>
<div class="text-stone-800 dark:text-stone-100" id="comments">
<button
class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900"
id="load-comments-button"
data-load-comments
>
<span>Click to load comments</span>
</button>
<p class="my-1">
<a class="text-link underline" href={link} target="_blank">View comments on Mastodon</a>
</p>
</div>
</section>
<template id="template-comments-loading">
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24">
<template id="template-button">
<button
class="mx-auto w-64 rounded-lg bg-bm-300 px-4 py-1 underline disabled:bg-bm-400 disabled:no-underline dark:bg-green-800 dark:disabled:bg-green-900"
id="load-comments-button"
>
<span>Click to load comments</span>
</button>
</template>
<template id="template-button-loading">
<svg class="-mt-1 mr-1 inline h-5 w-5 animate-spin" fill="none" viewBox="0 0 24 24" aria-hidden>
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-100"
@ -45,12 +55,16 @@ const { instance, user, postId } = Astro.props;
<template id="template-comment-box">
<div class="my-2 rounded-md border-2 border-stone-400 bg-stone-200 p-2 dark:border-stone-600 dark:bg-stone-800">
<div class="ml-1">
<a data-author class="text-link flex items-center text-lg hover:underline focus:underline">
<a data-author class="text-link flex items-center text-lg hover:underline focus:underline" target="_blank">
<img data-avatar class="mr-2 w-10 rounded-full border border-stone-400 dark:border-stone-600" />
<span data-display-name></span>
</a>
<a data-post-link class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline">
<span class="mr-1" data-publish-date></span>
<a
data-post-link
class="text-link my-1 flex items-center text-sm font-light hover:underline focus:underline"
target="_blank"
>
<span class="mr-1" data-publish-date aria-label="Publish date"></span>
</a>
</div>
<div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
@ -72,11 +86,18 @@ const { instance, user, postId } = Astro.props;
</svg>
</div>
</div>
<div data-comment-thread class="-mb-2"></div>
<div data-comment-thread class="-mb-2" aria-hidden></div>
</div>
</template>
<script>
interface Post {
link: string;
instance: string;
user: string;
postId: string;
}
interface Emoji {
shortcode: string;
url: string;
@ -104,115 +125,131 @@ const { instance, user, postId } = Astro.props;
emojis: Emoji[];
}
interface ApiResponse {
ancestors: Comment[];
descendants: Comment[];
}
(function () {
const replaceEmojis = (text: string, emojis: Emoji[], imgClass: string) =>
emojis.reduce(
function replaceEmojis(text: string, emojis: Emoji[]) {
return emojis.reduce(
(acc, emoji) =>
acc.replaceAll(
`:${emoji.shortcode}:`,
`<img class="${imgClass}" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
`<img class="inline mx-[1px] w-5" alt=":${emoji.shortcode}: emoji" src="${emoji.url}" />`,
),
text,
);
}
const commentSection = document.querySelector<Element>("#comment-section")!;
const instance = commentSection.getAttribute("data-instance");
const user = commentSection.getAttribute("data-user");
const postId = commentSection.getAttribute("data-post-id");
if (instance && user && postId) {
commentSection.classList.remove("hidden");
commentSection.querySelector<HTMLButtonElement>("button[data-load-comments]")!.addEventListener("click", (e) => {
async function renderComments(section: Element, post: Post) {
const commentsDiv = section.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!;
try {
const response = await fetch(`https://${post.instance}/api/v1/statuses/${post.postId}/context`);
if (!response.ok) {
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
}
const data: ApiResponse = await response.json();
const commentsList: HTMLElement[] = [];
const commentMap: Record<string, number> = {};
const commentTemplate = document.querySelector<HTMLElementTagNameMap["template"]>(
"template#template-comment-box",
)!;
data.descendants.forEach((comment) => {
const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
const commentBoxAuthor = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-author]")!;
commentBoxAuthor.href = comment.account.url;
const avatar = commentBoxAuthor.querySelector<HTMLElementTagNameMap["img"]>("img[data-avatar]")!;
avatar.src = comment.account.avatar;
avatar.alt = `Profile picture of ${comment.account.username}`;
const displayName = commentBoxAuthor.querySelector<HTMLElementTagNameMap["span"]>("span[data-display-name]")!;
displayName.innerHTML = replaceEmojis(comment.account.display_name, comment.account.emojis);
const commentBoxPostLink = commentBox.querySelector<HTMLElementTagNameMap["a"]>("a[data-post-link]")!;
commentBoxPostLink.href = comment.url;
const publishDate =
commentBoxPostLink.querySelector<HTMLElementTagNameMap["span"]>("span[data-publish-date]")!;
publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
if (comment.edited_at) {
const edited = document.createElement("span");
edited.className = "italic";
edited.innerText = "(edited)";
commentBoxPostLink.appendChild(edited);
}
const commentBoxContent = commentBox.querySelector<HTMLElementTagNameMap["div"]>("div[data-content]")!;
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis);
const commentBoxFavorites = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-favorites]")!;
commentBoxFavorites.innerText = comment.favourites_count.toString();
const commentBoxReblogs = commentBox.querySelector<HTMLElementTagNameMap["span"]>("span[data-reblogs]")!;
commentBoxReblogs.innerText = comment.reblogs_count.toString();
if (comment.in_reply_to_id === post.postId || !(comment.in_reply_to_id in commentMap)) {
commentMap[comment.id] = commentsList.length;
commentsList.push(commentBox);
} else {
const commentsIndex = commentMap[comment.in_reply_to_id];
commentMap[comment.id] = commentsIndex;
const parentThreadDiv =
commentsList[commentsIndex].querySelector<HTMLElementTagNameMap["div"]>("div[data-comment-thread]")!;
parentThreadDiv.removeAttribute("aria-hidden");
parentThreadDiv.setAttribute("aria-label", "Replies");
parentThreadDiv.appendChild(commentBox);
}
});
if (commentsList.length === 0) {
commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="${post.link}" target="_blank">Be the first to join the conversation on Mastodon</a>.</p>`;
} else {
commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="${post.link}" target="_blank">by replying on Mastodon</a>.</p>`;
commentsDiv.append(...commentsList);
}
} catch (e) {
commentsDiv.innerHTML = `<p class="my-1">Unable to load comments. Please try again later.</p>`;
console.error("Fetch Mastodon comments error", e);
}
}
function initCommentSection() {
const commentSection = document.querySelector<HTMLElementTagNameMap["section"]>("section#comment-section");
if (!commentSection) {
return;
}
const post = {
link: commentSection.dataset.link,
instance: commentSection.dataset.instance,
user: commentSection.dataset.user,
postId: commentSection.dataset.postId,
};
if (!post.link || !post.instance || !post.user || !post.postId) {
return;
}
const loadCommentsButton = document
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button")!
.content.cloneNode(true) as HTMLButtonElement;
commentSection.querySelector<HTMLElementTagNameMap["div"]>("div#comments")!.replaceChildren(loadCommentsButton);
loadCommentsButton.addEventListener("click", (e) => {
e.preventDefault();
const loadCommentsButton = e.target as HTMLButtonElement;
loadCommentsButton.setAttribute("disabled", "true");
loadCommentsButton.replaceChildren(
(document.getElementById("template-comments-loading") as HTMLTemplateElement).content.cloneNode(true),
document
.querySelector<HTMLElementTagNameMap["template"]>("template#template-button-loading")!
.content.cloneNode(true),
);
const renderComments = async () => {
try {
if (!instance || !user || !postId) {
throw new Error(
`Cannot fetch comments without all fields (instance=${instance}, user=${user}, post-id=${postId})`,
);
}
const response = await fetch(`https://${instance}/api/v1/statuses/${postId}/context`);
if (!response.ok) {
throw new Error(`Received error status ${response.status} - ${response.statusText}!`);
}
const data: { ancestors: Comment[]; descendants: Comment[] } = await response.json();
const commentsList: HTMLElement[] = [];
const commentMap: Record<string, number> = {};
const commentTemplate = document.getElementById("template-comment-box") as HTMLTemplateElement;
data.descendants.forEach((comment) => {
const commentBox = commentTemplate.content.cloneNode(true) as HTMLDivElement;
const commentBoxAuthor = commentBox.querySelector<HTMLAnchorElement>("[data-author]")!;
commentBoxAuthor.href = comment.account.url;
commentBoxAuthor.target = "_blank";
const avatar = commentBoxAuthor.querySelector<HTMLImageElement>("[data-avatar]")!;
avatar.src = comment.account.avatar;
avatar.alt = `Profile picture of ${comment.account.username}`;
const displayName = commentBoxAuthor.querySelector<HTMLSpanElement>("[data-display-name]")!;
displayName.innerHTML = replaceEmojis(
comment.account.display_name,
comment.account.emojis,
"inline mx-[1px] w-5",
);
const commentBoxPostLink = commentBox.querySelector<HTMLAnchorElement>("[data-post-link]")!;
commentBoxPostLink.href = comment.url;
commentBoxPostLink.target = "_blank";
const publishDate = commentBoxPostLink.querySelector<HTMLSpanElement>("[data-publish-date]")!;
publishDate.innerText = new Date(Date.parse(comment.created_at)).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
if (comment.edited_at) {
const edited = document.createElement("span");
edited.className = "italic";
edited.innerText = "(edited)";
commentBoxPostLink.appendChild(edited);
}
const commentBoxContent = commentBox.querySelector<HTMLDivElement>("[data-content]")!;
commentBoxContent.innerHTML = replaceEmojis(comment.content, comment.emojis, "inline mx-[1px] w-5");
const commentBoxFavorites = commentBox.querySelector<HTMLSpanElement>("[data-favorites]")!;
commentBoxFavorites.innerText = comment.favourites_count.toString();
const commentBoxReblogs = commentBox.querySelector<HTMLSpanElement>("[data-reblogs]")!;
commentBoxReblogs.innerText = comment.reblogs_count.toString();
if (comment.in_reply_to_id === postId || !(comment.in_reply_to_id in commentMap)) {
commentMap[comment.id] = commentsList.length;
commentsList.push(commentBox);
} else {
const commentsIndex = commentMap[comment.in_reply_to_id];
commentMap[comment.id] = commentsIndex;
commentsList[commentsIndex]
.querySelector<HTMLDivElement>("[data-comment-thread]")!
.appendChild(commentBox);
}
});
const commentsDiv = commentSection.querySelector<HTMLDivElement>("#comments")!;
if (commentsList.length === 0) {
commentsDiv.innerHTML = `<p class="my-1">No comments yet. <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">Be the first to join the conversation on Mastodon</a>.</p>`;
} else {
commentsDiv.innerHTML = `<p class="my-1">Join the conversation <a class="text-link underline" href="https://${instance}/@${user}/${postId}" target="_noblank">by replying on Mastodon</a>.</p>`;
commentsDiv.append(...commentsList);
}
} catch (e) {
loadCommentsButton.innerHTML = `<span>Unable to load comments.</span>`;
console.error("Fetch Mastodon comments error", e);
}
};
renderComments();
renderComments(commentSection, post as Post);
});
}
initCommentSection();
})();
</script>

View file

@ -1,5 +1,5 @@
---
import { type Lang } from "../content/config";
import type { Lang } from "../content/config";
import { t } from "../i18n";
type Props = {
@ -12,4 +12,12 @@ const requesters = Astro.slots.has("default")
: [];
---
{requesters.length ? <p id="requesters" set:html={t(lang, "story/requested_by", requesters)} /> : null}
{
requesters.length ? (
<p
id="requesters"
aria-label={t(lang, "story/requesters_aria_label", requesters)}
set:html={t(lang, "story/requested_by", requesters)}
/>
) : null
}

View file

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

View file

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

View file

@ -2,89 +2,214 @@ import { defineCollection, reference, z } from "astro:content";
// Constants
export const WEBSITE_LIST = [
"website",
"eka",
"furaffinity",
"weasyl",
"inkbunny",
"sofurry",
"mastodon",
"twitter",
"bluesky",
"itaku",
] as const;
export const GAME_PLATFORMS = ["web", "windows", "linux", "macos", "android", "ios"] as const;
export const DEFAULT_LANG = "eng";
export const DEFAULT_AUTHOR_ID = "bad-manners";
export const DEFAULT_LANG = "en";
export const ANONYMOUS_USER_ID = "anonymous";
// Validators
const ekaPostUrlRegex = /^(?:https:\/\/)(?:www\.)?aryion\.com\/g4\/view\/([1-9]\d*)\/?$/;
const furaffinityPostUrlRegex = /^(?:https:\/\/)(?:www\.)?furaffinity\.net\/view\/([1-9]\d*)\/?$/;
const weasylPostUrlRegex =
/^(?:https:\/\/)(?:www\.)?weasyl\.com\/~([a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/([1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/;
const inkbunnyPostUrlRegex = /^(?:https:\/\/)(?:www\.)?inkbunny\.net\/s\/([1-9]\d*)\/?$/;
const sofurryPostUrlRegex = /^(?:https:\/\/)www\.sofurry\.com\/view\/([1-9]\d*)\/?$/;
const mastodonPostUrlRegex = /^(?:https:\/\/)((?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@([a-zA-Z][a-zA-Z0-9_-]+)\/([1-9]\d*)\/?$/;
const refineAuthors = [
(value: { id: any } | any[]) => "id" in value || value.length > 0,
`"authors" cannot be empty`,
] as const;
const refineCopyrightedCharacters = [
(value: Record<string, any>) => !("" in value) || Object.keys(value).length == 1,
`"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
] as const;
// Transformers
const trimText = (text: string) => text.trim();
const adjustDateForUTCOffset = (date: Date) =>
new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0);
const parseMastodonPostUrl = (url: string, ctx: z.RefinementCtx) => {
const match = mastodonPostUrlRegex.exec(url);
if (!match) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"mastodon" post contains an invalid URL`,
});
return z.NEVER;
}
return {
instance: match[1]!,
user: match[2]!,
postId: match[3]!,
function parseRegex<R extends { [key: string]: string }>(regex: RegExp) {
return (url: string, ctx: z.RefinementCtx) => {
const match = regex.exec(url);
if (!match?.groups) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `"${ctx.path}" did not match regex`,
});
return z.NEVER;
}
return match.groups as R;
};
};
}
// Schema definitions
/** Record of website links for a user.
*
* For each entry, you can enter a URL for the value or - for any key apart
* from `website` - a pre-parsed object containing the link and username.
*/
const websiteLinks = z.object({
website: z
.string()
.url()
.transform((link) => {
link;
}),
eka: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(
/^(?:https?:\/\/)?(?:www\.)?aryion\.com\/g4\/user\/(?<username>[^\/]+)\/?$/,
)(link, ctx);
return { link, username };
}),
),
furaffinity: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(
/^(?:https?:\/\/)?(?:www\.)?furaffinity\.net\/user\/(?<username>[^\/]+)\/?$/,
)(link, ctx);
return { link, username };
}),
),
weasyl: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(
/^(?:https?:\/\/)?(?:www\.)?weasyl\.com\/\~(?<username>[^\/]+)\/?$/,
)(link, ctx);
return { link, username };
}),
),
inkbunny: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(
/^(?:https?:\/\/)?(?:www\.)?inkbunny\.net\/(?<username>[^\/]+)\/?$/,
)(link, ctx);
return { link, username };
}),
),
sofurry: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(/^(?:https?:\/\/)?(?<username>[^\.]+).sofurry.com\/?$/)(
link,
ctx,
);
return { link, username };
}),
),
mastodon: z
.object({ link: z.string().url(), username: z.string().regex(/^[^@]+@[^@]+$/) })
.or(
z.string().transform((link, ctx) => {
const { username, instance } = parseRegex<{ username: string; instance: string }>(
/^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/(?:@|users\/)(?<username>[a-zA-Z][a-zA-Z0-9_-]+)\/?$/,
)(link, ctx);
return { link, username: `${username}@${instance}` };
}),
)
.transform(({ link, username }) => {
const i = username.indexOf("@");
return { link, username, handle: username.slice(0, i), instance: username.slice(i + 1) };
}),
twitter: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(
/^(?:https?:\/\/)?(?:www\.)?(?:twitter\.com|x\.com)\/@?(?<username>[^\/]+)\/?$/,
)(link, ctx);
return { link, username };
}),
),
bluesky: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(
/^(?:https?:\/\/)?bsky\.app\/profile\/(?<username>[^\/]+)\/?$/,
)(link, ctx);
return { link, username };
}),
),
itaku: z.object({ link: z.string().url(), username: z.string() }).or(
z.string().transform((link, ctx) => {
const { username } = parseRegex<{ username: string }>(
/^(?:https?:\/\/)?(?:www\.)?itaku\.ee\/profile\/(?<username>[^\/]+)\/?$/,
)(link, ctx);
return { link, username };
}),
),
});
/** Available languages. See https://r12a.github.io/app-subtags/ */
const lang = z.enum(["en", "tok"]).default(DEFAULT_LANG);
/** Platforms for a game. */
const platform = z.enum(["web", "windows", "linux", "macos", "android", "ios"]);
const userList = z
.array(reference("users"))
.refine((value) => value.length > 0, `user list cannot be empty`)
.or(reference("users").transform((user) => [user]));
/** A record of the format `{"Character name": "user-id"}`.
*
* An empty character name `""` indicates that all characters are copyrighted
* by a certain user.
*/
const copyrightedCharacters = z
.record(z.string(), reference("users"))
.refine(
(value) => !("" in value) || Object.keys(value).length == 1,
`"copyrightedCharacters" cannot mix empty catch-all key with other keys`,
)
.default({});
/** A record of the format `{ en: string, tok?: string, ... }`. */
const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
/** Common attributes for published content (stories + games). */
const publishedContent = z.object({
// Required parameters
title: z.string(),
authors: userList,
contentWarning: z.string().trim(),
// Required parameters, but optional for drafts (isDraft == true)
pubDate: z
.date()
.transform((date: Date) => new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0))
.optional(),
description: z.string().trim(),
tags: z.array(z.string()),
// Optional parameters
isDraft: z.boolean().default(false),
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
lang: lang,
copyrightedCharacters: copyrightedCharacters,
series: reference("series").optional(),
posts: z
.object({
eka: z.string().transform((link, ctx) => {
const { postId } = parseRegex<{ postId: string }>(
/^(?:https?:\/\/)(?:www\.)?aryion\.com\/g4\/view\/(?<postId>[1-9]\d*)\/?$/,
)(link, ctx);
return { link, postId };
}),
furaffinity: z.string().transform((link, ctx) => {
const { postId } = parseRegex<{ postId: string }>(
/^(?:https?:\/\/)(?:www\.)?furaffinity\.net\/view\/(?<postId>[1-9]\d*)\/?$/,
)(link, ctx);
return { link, postId };
}),
weasyl: z.string().transform((link, ctx) => {
const { user, postId } = parseRegex<{ user: string; postId: string }>(
/^(?:https?:\/\/)(?:www\.)?weasyl\.com\/~(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/submissions\/(?<postId>[1-9]\d*(?:\/[a-zA-Z0-9_-]+)?)\/?$/,
)(link, ctx);
return { link, user, postId };
}),
inkbunny: z.string().transform((link, ctx) => {
const { postId } = parseRegex<{ postId: string }>(
/^(?:https?:\/\/)(?:www\.)?inkbunny\.net\/s\/(?<postId>[1-9]\d*)\/?$/,
)(link, ctx);
return { link, postId };
}),
sofurry: z.string().transform((link, ctx) => {
const { postId } = parseRegex<{ postId: string }>(
/^(?:https?:\/\/)www\.sofurry\.com\/view\/(?<postId>[1-9]\d*)\/?$/,
)(link, ctx);
return { link, postId };
}),
bluesky: z.string().transform((link, ctx) => {
const { user, postId } = parseRegex<{ user: string; postId: string }>(
/^(?:https?:\/\/)bsky\.app\/profile\/(?<user>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/post\/(?<postId>[a-z0-9]+)\/?$/,
)(link, ctx);
return { link, user, postId };
}),
mastodon: z.string().transform((link, ctx) => {
const { instance, user, postId } = parseRegex<{ instance: string; user: string; postId: string }>(
/^(?:https?:\/\/)(?<instance>(?:[a-zA-Z0-9_-]+\.)+[a-z]+)\/@(?<user>[a-zA-Z][a-zA-Z0-9_-]+)\/(?<postId>[1-9]\d*)\/?$/,
)(link, ctx);
return { link, instance, user, postId };
}),
})
.partial()
.default({}),
});
// Types
const lang = z.enum(["eng", "tok"]).default(DEFAULT_LANG);
const website = z.enum(WEBSITE_LIST);
const platform = z.enum(GAME_PLATFORMS);
const mastodonPost = z
.object({
instance: z.string(),
user: z.string(),
postId: z.string(),
})
.or(z.string().transform(parseMastodonPostUrl));
const userList = z
.array(reference("users"))
.or(reference("users").transform((user) => [user]))
.refine((value) => value.length > 0, `user list cannot be empty`);
const authors = userList.default([DEFAULT_AUTHOR_ID]);
const copyrightedCharacters = z
.record(z.string(), reference("users"))
.default({})
.refine(...refineCopyrightedCharacters);
// { eng: string, tok?: string, ... }
const langRecord = z.object({ [DEFAULT_LANG]: z.string() }).and(z.record(lang, z.string()));
export type Lang = z.output<typeof lang>;
export type Website = z.infer<typeof website>;
export type Website = keyof z.input<typeof websiteLinks>;
export type GamePlatform = z.infer<typeof platform>;
export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
@ -93,79 +218,52 @@ export type CopyrightedCharacters = z.infer<typeof copyrightedCharacters>;
const storiesCollection = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
// Required
title: z.string(),
wordCount: z.number().int().optional(),
contentWarning: z.string().transform(trimText),
description: z.string().transform(trimText),
tags: z.array(z.string()),
// Optional
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
isDraft: z.boolean().default(false),
shortTitle: z.string().optional(),
authors,
summary: z.string().transform(trimText).optional(),
thumbnail: image().optional(),
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
series: reference("series").optional(),
commissioner: userList.optional(),
requester: userList.optional(),
copyrightedCharacters: copyrightedCharacters,
lang,
prev: reference("stories").nullish(),
next: reference("stories").nullish(),
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
posts: z
.object({
eka: z.string().regex(ekaPostUrlRegex),
furaffinity: z.string().regex(furaffinityPostUrlRegex),
weasyl: z.string().regex(weasylPostUrlRegex),
inkbunny: z.string().regex(inkbunnyPostUrlRegex),
sofurry: z.string().regex(sofurryPostUrlRegex),
mastodon: mastodonPost,
})
.partial()
.default({}),
}),
z
.object({
// Required parameters, but optional for drafts (isDraft == true)
wordCount: z.number().int().optional(),
thumbnail: image().optional(),
// Optional parameters
shortTitle: z.string().optional(),
commissioner: userList.optional(),
requester: userList.optional(),
summary: z.string().trim().optional(),
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
prev: reference("stories").nullish(),
next: reference("stories").nullish(),
})
.and(publishedContent)
.refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published story`)
.refine(
({ isDraft, contentWarning }) => isDraft || contentWarning,
`Missing "contentWarning" for published story`,
)
.refine(({ isDraft, wordCount }) => isDraft || wordCount, `Missing "wordCount" for published story`)
.refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published story`)
.refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published story`)
.refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published story`),
});
const gamesCollection = defineCollection({
type: "content",
schema: ({ image }) =>
z.object({
// Required
title: z.string(),
contentWarning: z.string().transform(trimText),
description: z.string().transform(trimText),
platforms: z.array(platform).refine((platforms) => platforms.length > 0, `"platforms" cannot be empty`),
tags: z.array(z.string()),
// Optional
pubDate: z.date().transform(adjustDateForUTCOffset).optional(),
isDraft: z.boolean().default(false),
authors,
thumbnail: image().optional(),
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
series: reference("series").optional(),
copyrightedCharacters: copyrightedCharacters,
lang,
relatedStories: z.array(reference("stories")).default([]),
relatedGames: z.array(reference("games")).default([]),
posts: z
.object({
eka: z.string().regex(ekaPostUrlRegex),
furaffinity: z.string().regex(furaffinityPostUrlRegex),
weasyl: z.string().regex(weasylPostUrlRegex),
inkbunny: z.string().regex(inkbunnyPostUrlRegex),
sofurry: z.string().regex(sofurryPostUrlRegex),
mastodon: mastodonPost,
})
.partial()
.default({}),
}),
z
.object({
// Required parameters, but optional for drafts (isDraft == true)
platforms: z.array(platform).default([]),
thumbnail: image().optional(),
// Optional parameters
thumbnailWidth: z.number().int().optional(),
thumbnailHeight: z.number().int().optional(),
})
.and(publishedContent)
.refine(({ isDraft, description }) => isDraft || description, `Missing "description" for published game`)
.refine(({ isDraft, contentWarning }) => isDraft || contentWarning, `Missing "contentWarning" for published game`)
.refine(({ isDraft, platforms }) => isDraft || platforms.length, `Missing "platforms" for published game`)
.refine(({ isDraft, pubDate }) => isDraft || pubDate, `Missing "pubDate" for published game`)
.refine(({ isDraft, thumbnail }) => isDraft || thumbnail, `Missing "thumbnail" for published game`)
.refine(({ isDraft, tags }) => isDraft || tags.length, `Missing "tags" for published game`),
});
// Data collections
@ -175,12 +273,11 @@ const usersCollection = defineCollection({
schema: ({ image }) =>
z
.object({
// Required
name: z.string(),
links: z.record(website, z.union([z.string().url(), z.tuple([z.string().url(), z.string()])])),
// Optional
preferredLink: website.nullish(),
lang: langRecord.optional(),
// Required parameters
name: langRecord.or(z.string()),
links: websiteLinks.partial(),
// Optional parameters
preferredLink: websiteLinks.keyof().nullish(),
avatar: image().optional(),
})
.refine(
@ -195,7 +292,7 @@ const usersCollection = defineCollection({
const seriesCollection = defineCollection({
type: "data",
schema: z.object({
// Required
// Required parameters
name: z.string(),
url: z.string().regex(/^(\/[a-z0-9_-]+)+\/?$/, `"url" must be a local URL`),
}),
@ -204,16 +301,18 @@ const seriesCollection = defineCollection({
const tagCategoriesCollection = defineCollection({
type: "data",
schema: z.object({
// Required
// Required parameters
name: z.string(),
index: z.number().int(),
tags: z.array(
z.object({
name: z.union([z.string(), langRecord]),
description: z.string().optional(),
related: z.array(z.string()).optional(),
}),
),
tags: z
.array(
z.object({
name: langRecord.or(z.string()),
description: z.string().trim().optional(),
related: z.array(z.string()).default([]),
}),
)
.refine((tags) => tags.length, `"tags" cannot be empty`),
}),
});

View file

@ -28,6 +28,7 @@ posts:
inkbunny: https://inkbunny.net/s/3262911
sofurry: https://www.sofurry.com/view/2109688
weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game
bluesky: https://bsky.app/profile/badmanners.xyz/post/3kmigrf5q2x24
mastodon: https://meow.social/@BadManners/112009918919441027
tags:
- oral vore

View file

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

View file

@ -16,6 +16,7 @@ posts:
inkbunny: https://inkbunny.net/s/3283508
sofurry: https://www.sofurry.com/view/2118138
weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
bluesky: https://bsky.app/profile/badmanners.xyz/post/3kok52wijz32c
mastodon: https://meow.social/@BadManners/112157812554023271
tags:
- anthro predator

View file

@ -1,7 +1,7 @@
name: Types of vore
index: 1
tags:
- name: { eng: oral vore, tok: moku musi kepeken uta }
- name: { en: oral vore, tok: moku musi kepeken uta }
description: Scenarios where prey are consumed by the predator through their mouth.
- name: anal vore
description: Scenarios where prey are consumed by the predator through their butt/anus.

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.
- name: taur predator
description: Scenarios where at least one of the predators is a multi-legged centaur-like creature, with an animal lower body and anthropomorphic upper body.
- name: { eng: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
- name: { en: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
description: Scenarios where the body type of at least one of the predators is left ambiguous.
- name: human prey
description: Scenarios where at least one of the prey is a human person.
@ -15,5 +15,5 @@ tags:
description: Scenarios where at least one of the prey is an anthropomorphic animal, i.e. generally regarded as a "furry".
- name: feral prey
description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
- name: { eng: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
- name: { en: ambiguous prey, tok: sijelo pi jan pi wawa lili li ale }
description: Scenarios where the body type of at least one of the predators is left ambiguous.

View file

@ -13,7 +13,7 @@ tags:
description: Scenarios where at least one of the predators is a woman and/or female-presenting.
- name: non-binary predator
description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
- name: { eng: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
- name: { en: ambiguous gender predator, tok: jan pi wawa mute li meli anu mije }
description: Scenarios where the gender at least one of the predators is left ambiguous.
- name: male prey
description: Scenarios where at least one of the prey is a man and/or male-presenting.
@ -27,5 +27,5 @@ tags:
- female prey
- name: non-binary prey
description: Scenarios where at least one of the predators has a non-binary gender expression, be they genderless/agender, intersex, androgynous, gender-fluid, non-binary and/or non-binary-presenting, et cetera, regardless of undergoing or having undergone gender transition or not.
- name: { eng: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
- name: { en: ambiguous gender prey, tok: jan pi wawa lili li meli anu mije }
description: Scenarios where the gender at least one of the predators is left ambiguous.

View file

@ -1,7 +1,7 @@
name: Willingness
index: 5
tags:
- name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi }
- name: { en: willing predator, tok: jan pi wawa mute li wile e moku musi }
description: Scenarios where at least one of the predators participates in vore willingly.
- name: semi-willing predator
description: Scenarios where the willingness of at least one of the predators might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
@ -13,7 +13,7 @@ tags:
description: Scenarios where at least one of the prey participates in vore willingly.
- name: semi-willing prey
description: Scenarios where the willingness of at least one of the prey might be partial, oscillating between willing and unwilling, somewhere in-between, or ambiguous.
- name: { eng: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
- name: { en: unwilling prey, tok: jan pi wawa lili li wile ala e moku musi }
description: Scenarios where at least one of the prey participates in vore unwillingly.
- name: asleep prey
description: Scenarios where at least one of the predators participates in vore while asleep.

View file

@ -66,4 +66,4 @@ tags:
- name: soul vore
description: Scenarios where predators consume a soul instead of their prey's body.
- name: Vore Day
description: Stories created in commemoration of Vore Day, which is celebrated on August 8ᵗʰ, and/or are set in said day.
description: Stories created in commemoration of Vore Day, which is celebrated on August 8th, and/or are set in said day.

View file

@ -5,7 +5,7 @@ tags:
description: Stories made by someone else's request, as a gift.
- name: commission
description: Stories made as part of a commission to someone else.
- name: { eng: flash fiction, tok: lipu lili }
- name: { en: flash fiction, tok: lipu lili }
description: Short-format stories of no more than 2,500 words.
- name: toki pona
description: Stories written in toki pona, the language of good.

View file

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

View file

@ -1,7 +1,7 @@
name: Asof Yeun
links:
eka: https://aryion.com/g4/user/asofyeun
furaffinity: https://www.furaffinity.net/user/asofyeun
furaffinity: https://www.furaffinity.net/user/AsofYeun
inkbunny: https://inkbunny.net/asofyeun
sofurry: https://asofyeun.sofurry.com/
weasyl: https://www.weasyl.com/~asofyeun

View file

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

View file

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

View file

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

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";
export { DEFAULT_LANG } from "../content/config";
const UI_STRINGS = {
// Utility functions
"util/join_names": {
eng: (names: string[]) =>
en: (names: string[]) =>
names.length <= 1
? names.join("")
: names.length == 2
@ -13,10 +14,10 @@ const UI_STRINGS = {
tok: (names: string[]) => names.join(" en "),
},
"util/capitalize": {
eng: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
en: (text: string) => (text.length > 0 ? `${text[0].toUpperCase()}${text.slice(1)}` : ""),
},
"util/enumerate": {
eng: (count: number, nounSingular: string, nounPlural: string | undefined) => {
en: (count: number, nounSingular: string, nounPlural?: string) => {
if (count == 0) {
return `no ${nounPlural ?? nounSingular}`;
}
@ -25,146 +26,205 @@ const UI_STRINGS = {
}
return `${count} ${nounPlural ?? nounSingular}`;
},
tok: (count: number, nounSingular: string, nounPlural: string | undefined) =>
tok: (count: number, nounSingular: string, nounPlural?: string) =>
`${(count > 1 && nounPlural) || nounSingular} ${["ala", "wan", "tu"][count] || "mute"}`,
},
"export_story/writing": {
eng: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
// export-story API functions
"export_story/authors": {
en: (authorsList: string[]) => `Writing: ${authorsList.join(" ")}`,
tok: (authorsList: string[]) => `lipu ni li tan jan ni: ${authorsList.join(" en ")}`,
},
"export_story/request_for": {
eng: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`,
en: (requesterList: string[]) => `Request for: ${requesterList.join(" ")}`,
},
"export_story/commissioned_by": {
eng: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`,
en: (commissionerList: string[]) => `Commissioned by: ${commissionerList.join(" ")}`,
},
"story/return_to_stories": {
eng: "Return to stories",
tok: "o tawa e lipu ale",
// Shared strings for published content (stories + games)
"published_content/return_to_series": {
en: (seriesName: string) => `Return to ${seriesName}`,
},
"story/return_to_series": {
eng: (seriesName: string) => `Return to ${seriesName}`,
},
"story/go_to_description": {
eng: "Go to description",
"published_content/go_to_description": {
en: "Go to description",
tok: "o tawa e toki lipu",
},
"story/toggle_dark_mode": {
eng: "Toggle dark mode",
"published_content/toggle_dark_mode": {
en: "Toggle dark mode",
tok: "o ante e kule lipu",
},
"published_content/cover_art_alt": {
en: (title: string) => `Cover art for ${title}`,
tok: (_title: string) => `sitelen tawa lipu ni`,
},
"published_content/publish_date": {
en: (date: Date) => date.toISOString().slice(0, 10),
tok: (date: Date) => `tenpo suno ${date.toISOString().slice(0, 10)}`,
},
"published_content/publish_date_aria_label": {
en: "Publish date",
tok: "tenpo pi pana lipu",
},
"published_content/publish_date_aria_description": {
en: (date: Date) =>
date.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
}),
tok: (_date: Date) => "",
},
"published_content/description": {
en: "Description",
tok: "toki lipu",
},
"published_content/to_top": {
en: "To top",
tok: "tawa sewi",
},
"published_content/tags": {
en: "Tags",
tok: "nimi kulupu",
},
"published_content/copyright_year": {
en: (year: string | number) => `© ${year}`,
tok: (year: string | number) => `© tenpo pi sike suno ${year}`,
},
"published_content/licenses": {
en: "Licenses",
tok: "lipu lawa",
},
"published_content/draft_warning": {
en: "DRAFT VERSION DO NOT REDISTRIBUTE",
},
"published_content/related_stories": {
en: "Related stories",
},
"published_content/related_games": {
en: "Related games",
},
// Story page-specific strings
"story/return_to_stories": {
en: "Return to stories",
tok: "o tawa e lipu ale",
},
"story/title_aria_label": {
en: "Story title",
tok: "nimi lipu",
},
"story/authors_aria_label": {
en: (authors: any[]) => (authors.length == 1 ? "Author" : "Authors"),
tok: (_authors: any[]) => "jan pi pali lipu",
},
"story/requesters_aria_label": {
en: (requesters: any[]) => (requesters.length == 1 ? "Requester" : "Requesters"),
},
"story/commissioners_aria_label": {
en: (commissioners: any[]) => (commissioners.length == 1 ? "Commissioner" : "Commissioners"),
},
"story/warnings": {
eng: (wordCount: number | string | undefined, contentWarning: string) =>
en: (wordCount: number | string | undefined, contentWarning: string) =>
wordCount ? `Word count: ${wordCount}. ${contentWarning}` : contentWarning,
tok: (_wordCount: number | string | undefined, contentWarning: string) => contentWarning,
},
"story/publish_date": {
eng: (date: string) => date,
tok: (date: string) => `tenpo suno ${date}`,
},
"story/description": {
eng: "Description",
tok: "toki lipu",
"story/article_aria_label": {
en: "Story",
tok: "lipu",
},
"story/summary": {
eng: "Summary",
en: "Summary",
tok: "lipu tawa tenpo lili",
},
"story/reveal_summary": {
eng: "Click to reveal",
en: "Click to reveal",
tok: "Click to reveal summary in English",
},
"story/to_top": {
eng: "To top",
tok: "tawa sewi",
},
"story/tags": {
eng: "Tags",
tok: "nimi kulupu",
},
"story/copyright_year": {
eng: (year: string | number) => `© ${year}`,
tok: (year: string | number) => `© tenpo pi sike suno ${year}`,
},
"story/licenses": {
eng: "Licenses",
tok: "lipu lawa",
},
"story/authors": {
eng: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].eng(authorsList)}`,
en: (authorsList: string[]) => `by ${UI_STRINGS["util/join_names"].en(authorsList)}`,
tok: (authorsList: string[]) =>
authorsList.length > 1
? `lipu ni li tan jan ni: ${UI_STRINGS["util/join_names"].tok(authorsList)}`
: `lipu ni li tan ${authorsList[0]}`,
},
"story/commissioned_by": {
eng: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].eng(commissionersList)}`,
en: (commissionersList: string[]) => `Commissioned by ${UI_STRINGS["util/join_names"].en(commissionersList)}`,
},
"story/requested_by": {
eng: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].eng(requestersList)}`,
en: (requestersList: string[]) => `Requested by ${UI_STRINGS["util/join_names"].en(requestersList)}`,
},
"story/draft_warning": {
eng: "DRAFT VERSION DO NOT REDISTRIBUTE",
// Game page-specific strings
"game/return_to_games": {
en: "Return to games",
},
"characters/characters_are_copyrighted_by": {
eng: (owner: string, charactersList: string[]) =>
charactersList.length == 1
? `${charactersList[0]} is © ${owner}`
: `${UI_STRINGS["util/join_names"].eng(charactersList)} are © ${owner}`,
},
"characters/all_characters_are_copyrighted_by": {
eng: (owner: string) => `All characters are © ${owner}`,
"game/title_aria_label": {
en: "Game title",
},
"game/platforms": {
eng: (platforms: GamePlatform[]) => {
en: (platforms: GamePlatform[]) => {
if (platforms.length == 0) {
return "";
}
const translatedPlatforms = platforms.map((platform) => {
const platformLang = UI_STRINGS[`game/platform_${platform}`].eng;
const platformLang = UI_STRINGS[`game/platform_${platform}`].en;
if (!platformLang) {
throw new Error(`Invalid platform "${platform}"`);
}
return platformLang;
});
return `A game for ${UI_STRINGS["util/join_names"].eng(translatedPlatforms)}.`;
return `A game for ${UI_STRINGS["util/join_names"].en(translatedPlatforms)}.`;
},
},
"game/platform_web": {
eng: "web browsers",
en: "web browsers",
},
"game/platform_windows": {
eng: "Windows",
en: "Windows",
},
"game/platform_linux": {
eng: "Linux",
en: "Linux",
},
"game/platform_macos": {
eng: "macOS",
en: "macOS",
},
"game/platform_android": {
eng: "Android",
en: "Android",
},
"game/platform_ios": {
eng: "iOS",
en: "iOS",
},
"game/warnings": {
eng: (platforms: GamePlatform[], contentWarning: string) =>
platforms.length > 0 ? `${UI_STRINGS["game/platforms"].eng(platforms)} ${contentWarning}` : contentWarning,
en: (platforms: GamePlatform[], contentWarning: string) =>
platforms.length > 0 ? `${UI_STRINGS["game/platforms"].en(platforms)} ${contentWarning}` : contentWarning,
},
"game/article_aria_label": {
en: "Game",
},
// Copyrighted character-related strings
"characters/copyrighted_characters_aria_label": {
en: "Copyrighted characters",
},
"characters/characters_are_copyrighted_by": {
en: (owner: string, charactersList: string[]) =>
charactersList.length == 1
? `${charactersList[0]} is © ${owner}`
: `${UI_STRINGS["util/join_names"].en(charactersList)} are © ${owner}`,
},
"characters/all_characters_are_copyrighted_by": {
en: (owner: string) => `All characters are © ${owner}`,
},
// Tag-related strings
"tag/total_works_with_tag": {
eng: (tag: string, storiesCount: number, gamesCount: number) => {
en: (tag: string, storiesCount: number, gamesCount: number) => {
const content = [];
if (storiesCount > 0) {
content.push(UI_STRINGS["util/enumerate"].eng(storiesCount, "story", "stories"));
content.push(UI_STRINGS["util/enumerate"].en(storiesCount, "story", "stories"));
}
if (gamesCount > 0) {
content.push(UI_STRINGS["util/enumerate"].eng(gamesCount, "game", "games"));
content.push(UI_STRINGS["util/enumerate"].en(gamesCount, "game", "games"));
}
if (content.length == 0) {
return `No works tagged with "${tag}".`;
}
return UI_STRINGS["util/capitalize"].eng(`${UI_STRINGS["util/join_names"].eng(content)} tagged with "${tag}".`);
return UI_STRINGS["util/capitalize"].en(`${UI_STRINGS["util/join_names"].en(content)} tagged with "${tag}".`);
},
},
} as const;
@ -176,6 +236,9 @@ type TranslationEntry<T> = { [DEFAULT_LANG]: T } & {
type TranslationArgs<K extends TranslationKey> =
(typeof UI_STRINGS)[K] extends TranslationEntry<infer T> ? (T extends (...args: infer A) => string ? A : []) : never;
/** Translates some text according to the provided language, a translation key,
* and optionally any required arguments.
*/
export function t<K extends TranslationKey>(lang: Lang, key: K, ...args: TranslationArgs<K>): string {
if (key in UI_STRINGS) {
const translation: string | ((...args: TranslationArgs<K>) => string) =

View file

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

View file

@ -18,8 +18,8 @@ const { props } = Astro;
const series = props.series && (await getEntry(props.series));
const authorsList = await getEntries(props.authors);
const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrightedCharacters);
// const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const relatedStories = (await getEntries(props.relatedStories)).filter((story) => !story.data.isDraft);
const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries(
(await getCollection("tag-categories")).flatMap((category) =>
category.data.tags.map<[string, string | null]>(({ name }) =>
@ -44,10 +44,10 @@ const thumbnail =
(await getImage({ src: props.thumbnail, width: props.thumbnailWidth, height: props.thumbnailHeight }));
---
<BaseLayout pageTitle={props.title}>
<BaseLayout pageTitle={props.title} lang={props.lang}>
<Fragment slot="head">
<meta property="og:title" content={props.title} data-pagefind-meta="title[content]" />
<meta property="og:description" content={props.contentWarning} />
<meta property="og:description" content={t(props.lang, "game/warnings", props.platforms, props.contentWarning)} />
<meta property="og:url" content={Astro.url} data-pagefind-meta="url[content]" />
{
thumbnail ? (
@ -55,7 +55,7 @@ const thumbnail =
<meta content={thumbnail.src} property="og:image" data-pagefind-meta="image[content]" />
<meta
property="og:image:alt"
content={`Cover art for ${props.title}`}
content={t(props.lang, "published_content/cover_art_alt", props.title)}
data-pagefind-meta="image_alt[content]"
/>
</Fragment>
@ -78,7 +78,9 @@ const thumbnail =
<a
href={series ? series.data.url : "/games"}
class="text-link my-1 h-9 w-9 p-2"
aria-label={`Return to ${series ? series.data.name : "games"}`}
aria-label={series
? t(props.lang, "published_content/return_to_series", series.data.name)
: t(props.lang, "game/return_to_games")}
>
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path
@ -89,7 +91,7 @@ const thumbnail =
<a
href="#description"
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
aria-label="Go to description"
aria-label={t(props.lang, "published_content/go_to_description")}
>
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path
@ -100,7 +102,7 @@ const thumbnail =
<button
data-dark-mode
class="text-link my-1 h-9 w-9 border-l border-stone-300 p-2 dark:border-stone-700"
aria-label="Toggle dark mode"
aria-label={t(props.lang, "published_content/toggle_dark_mode")}
>
<svg viewBox="0 0 512 512" class="hidden fill-current dark:block" aria-hidden="true">
<path
@ -120,7 +122,11 @@ const thumbnail =
data-pagefind-body={props.isDraft ? undefined : ""}
data-pagefind-meta="type:game"
>
<h1 id="game-title" class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100">
<h1
id="game-title"
class="px-2 pt-2 font-serif text-3xl font-semibold text-stone-800 dark:text-stone-100"
aria-label={t(props.lang, "game/title_aria_label")}
>
{props.title}
</h1>
<section
@ -136,7 +142,7 @@ const thumbnail =
{
props.isDraft ? (
<p id="draft-warning" class="py-2 text-center text-2xl font-semibold not-italic text-red-600">
{t(props.lang, "story/draft_warning")}
{t(props.lang, "published_content/draft_warning")}
</p>
) : null
}
@ -155,7 +161,7 @@ const thumbnail =
<img
loading="eager"
src={thumbnail.src}
alt={`Cover art for ${props.title}`}
alt={t(props.lang, "published_content/cover_art_alt", props.title)}
width={props.thumbnailWidth}
height={props.thumbnailHeight}
class="mx-auto my-5 shadow-lg"
@ -165,7 +171,7 @@ const thumbnail =
) : null
}
<hr class="mx-auto my-10 w-[80%] max-w-xl border-stone-400 dark:border-stone-600" />
<article id="game" class="pr-1 font-serif">
<article id="game" class="pr-1 font-serif" aria-label={t(props.lang, "game/article_aria_label")}>
<Prose>
<slot />
</Prose>
@ -177,28 +183,26 @@ const thumbnail =
id="draft-warning-bottom"
class="py-2 text-center font-serif text-2xl font-semibold not-italic text-red-600"
>
{t(props.lang, "story/draft_warning")}
{t(props.lang, "published_content/draft_warning")}
</p>
) : props.pubDate ? (
<p
id="publish-date"
class="mt-2 px-2 text-center font-serif font-light text-stone-600 dark:text-stone-200"
aria-label="Publish date"
aria-description={props.pubDate.toLocaleDateString("en-US", {
month: "long",
day: "numeric",
year: "numeric",
})}
aria-label={t(props.lang, "published_content/publish_date_aria_label")}
aria-description={
t(props.lang, "published_content/publish_date_aria_description", props.pubDate) || undefined
}
data-pagefind-index-attrs="aria-description"
data-pagefind-meta={`date:${props.pubDate.toISOString().slice(0, 10)}`}
>
{t(props.lang, "story/publish_date", props.pubDate.toISOString().slice(0, 10))}
{t(props.lang, "published_content/publish_date", props.pubDate)}
</p>
) : null
}
<section id="description" class="px-2 font-serif" aria-describedby="title-description">
<h2 id="title-description" class="py-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
{t(props.lang, "story/description")}
{t(props.lang, "published_content/description")}
</h2>
<Prose>
<Markdown of={props.description} />
@ -211,14 +215,50 @@ const thumbnail =
><path
d="M214.6 41.4c-12.5-12.5-32.8-12.5-45.3 0l-160 160c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 141.2V448c0 17.7 14.3 32 32 32s32-14.3 32-32V141.2L329.4 246.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3l-160-160z"
></path></svg
><span>{t(props.lang, "story/to_top")}</span></a
><span>{t(props.lang, "published_content/to_top")}</span></a
>
</div>
{
relatedStories.length > 0 ? (
<section id="related" aria-describedby="title-related" class="my-5">
<h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
{t(props.lang, "published_content/related_stories")}
</h2>
<Prose>
<ul>
{relatedStories.map((story) => (
<li>
<a href={`/stories/${story.slug}`}>{story.data.title}</a>
</li>
))}
</ul>
</Prose>
</section>
) : null
}
{
relatedGames.length > 0 ? (
<section id="related" aria-describedby="title-related" class="my-5">
<h2 id="title-related" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
{t(props.lang, "published_content/related_games")}
</h2>
<Prose>
<ul>
{relatedGames.map((game) => (
<li>
<a href={`/games/${game.slug}`}>{game.data.title}</a>
</li>
))}
</ul>
</Prose>
</section>
) : null
}
{
tags.length > 0 ? (
<section id="tags" aria-describedby="title-tags" class="my-5">
<h2 id="title-tags" class="p-2 font-serif text-xl font-semibold text-stone-800 dark:text-stone-100">
Tags
{t(props.lang, "published_content/tags")}
</h2>
<ul class="flex flex-wrap gap-x-2 gap-y-2 px-2">
{tags.map(({ id, name }) => (
@ -232,16 +272,12 @@ const thumbnail =
</section>
) : null
}
<MastodonComments
instance={props.posts.mastodon?.instance}
user={props.posts.mastodon?.user}
postId={props.posts.mastodon?.postId}
/>
{props.posts.mastodon ? <MastodonComments lang={props.lang} {...props.posts.mastodon} /> : null}
</main>
<div class="pt-6 text-center text-xs text-black dark:text-white">
<span>{t(props.lang, "story/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<span>{t(props.lang, "published_content/copyright_year", (props.pubDate || new Date()).getFullYear())} | </span>
<a class="hover:underline focus:underline" href="/licenses.txt" target="_blank"
>{t(props.lang, "story/licenses")}</a
>{t(props.lang, "published_content/licenses")}</a
>
</div>
</div>

View file

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

View file

@ -1,6 +1,6 @@
import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import type { Website } from "../../../content/config";
import { getCollection, type CollectionEntry, getEntries } from "astro:content";
import type { Lang, Website } from "../../../content/config";
import { t } from "../../../i18n";
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
@ -24,72 +24,8 @@ type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: in
function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
const link = user.data.links[website];
if (link) {
if (typeof link === "string") {
switch (website) {
case "website":
break;
case "eka":
const ekaMatch = link.match(/^.*\baryion\.com\/g4\/user\/([^\/]+)\/?$/);
if (ekaMatch && ekaMatch[1]) {
return ekaMatch[1];
}
break;
case "furaffinity":
const faMatch = link.match(/^.*\bfuraffinity\.net\/user\/([^\/]+)\/?$/);
if (faMatch && faMatch[1]) {
return faMatch[1];
}
break;
case "inkbunny":
const ibMatch = link.match(/^.*\binkbunny\.net\/([^\/]+)\/?$/);
if (ibMatch && ibMatch[1]) {
return ibMatch[1];
}
break;
case "sofurry":
const sfMatch = link.match(/^(?:https?:\/\/)?([^\.]+).sofurry.com\b.*$/);
if (sfMatch && sfMatch[1]) {
return sfMatch[1].replaceAll("-", " ");
}
break;
case "weasyl":
const weasylMatch = link.match(/^.*\bweasyl\.com\/\~([^\/]+)\/?$/);
if (weasylMatch && weasylMatch[1]) {
return weasylMatch[1];
}
break;
case "twitter":
const twitterMatch = link.match(/^.*(?:\btwitter\.com|\bx\.com)\/@?([^\/]+)\/?$/);
if (twitterMatch && twitterMatch[1]) {
return twitterMatch[1];
}
break;
case "mastodon":
const mastodonMatch = link.match(/^(?:https?\:\/\/)?([^\/]+)\/(?:@|users\/)([^\/]+)\/?$/);
if (mastodonMatch && mastodonMatch[1] && mastodonMatch[2]) {
return `${mastodonMatch[2]}@${mastodonMatch[1]}`;
}
break;
case "bluesky":
const bskyMatch = link.match(/^.*\bbsky\.app\/profile\/([^\/]+)\/?$/);
if (bskyMatch && bskyMatch[1]) {
return bskyMatch[1];
}
break;
case "itaku":
const itakuMatch = link.match(/^.*\bitaku\.ee\/profile\/([^\/]+)\/?$/);
if (itakuMatch && itakuMatch[1]) {
return itakuMatch[1];
}
break;
default:
let _: never = website;
throw new Error(`Unhandled website "${website}"`);
}
} else {
return link[1].replace(/^@/, "");
}
if (link?.username) {
return link.username;
}
throw new Error(`Cannot get "${website}" username for user "${user.id}"`);
}
@ -99,20 +35,21 @@ function isPreferredWebsite(user: CollectionEntry<"users">, website: Website): b
return !preferredLink || preferredLink == website;
}
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName): string {
function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteName, lang: Lang): string {
const { links, preferredLink } = user.data;
switch (website) {
case "eka":
if ("eka" in user.data.links) {
if ("eka" in links) {
return `:icon${getUsernameForWebsite(user, "eka")}:`;
}
break;
case "furaffinity":
if ("furaffinity" in user.data.links) {
if ("furaffinity" in links) {
return `:icon${getUsernameForWebsite(user, "furaffinity")}:`;
}
break;
case "weasyl":
if ("weasyl" in user.data.links) {
if ("weasyl" in links) {
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
} else if (isPreferredWebsite(user, "furaffinity")) {
return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
@ -123,7 +60,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
}
break;
case "inkbunny":
if ("inkbunny" in user.data.links) {
if ("inkbunny" in links) {
return `[iconname]${getUsernameForWebsite(user, "inkbunny")}[/iconname]`;
} else if (isPreferredWebsite(user, "furaffinity")) {
return `[fa]${getUsernameForWebsite(user, "furaffinity")}[/fa]`;
@ -134,7 +71,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
}
break;
case "sofurry":
if ("sofurry" in user.data.links) {
if ("sofurry" in links) {
return `:icon${getUsernameForWebsite(user, "sofurry")}:`;
} else if (isPreferredWebsite(user, "furaffinity")) {
return `fa!${getUsernameForWebsite(user, "furaffinity")}`;
@ -143,11 +80,12 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
}
break;
default:
throw new Error(`Unhandled ExportWebsite "${website}"`);
const unknown: never = website;
throw new Error(`Unhandled export website "${unknown}"`);
}
if (user.data.preferredLink) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
return `[${user.data.name}](${typeof preferredLink === "string" ? preferredLink : preferredLink[0]})`;
if (preferredLink) {
const preferred = links[preferredLink]!;
return `[${getUsernameForLang(user, lang)}](${preferred.link})`;
}
throw new Error(
`No matching "${website}" link for user "${user.id}" (consider setting their "preferredLink" property)`,
@ -182,14 +120,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
const description = Object.fromEntries(
WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
const u = (user: CollectionEntry<"users">) =>
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website);
isAnonymousUser(user) ? getUsernameForLang(user, lang) : getLinkForUser(user, website, lang);
const storyDescription = (
[
story.data.description,
`*${t(lang, "story/warnings", story.data.wordCount, story.data.contentWarning)}*`,
t(
lang,
"export_story/writing",
"export_story/authors",
authorsList.map((author) => u(author)),
),
requestersList &&
@ -216,12 +154,14 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
/\[([^\]]+)\]\((\/[^\)]+)\)/g,
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
);
if (exportFormat === "bbcode") {
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
} else if (exportFormat === "markdown") {
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
} else {
throw new Error(`Unhandled export format "${exportFormat}"`);
switch (exportFormat) {
case "bbcode":
return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n")];
case "markdown":
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
default:
const unknown: never = exportFormat;
throw new Error(`Unknown export format "${unknown}"`);
}
}),
);
@ -252,13 +192,12 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
.replaceAll(/\n\n\n+/g, "\n\n")
.trim();
const headers = { "Content-Type": "application/json; charset=utf-8" };
return new Response(
JSON.stringify({
story: storyText,
description,
thumbnail: story.data.thumbnail ? story.data.thumbnail.src : null,
}),
{ headers },
{ headers: { "Content-Type": "application/json; charset=utf-8" } },
);
};

View file

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

View file

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

View file

@ -18,17 +18,6 @@ export const getStaticPaths: GetStaticPaths = async () => {
};
const game = Astro.props;
if (!game.data.isDraft) {
if (!game.data.pubDate) {
throw new Error(`Missing "pubDate" for published game ${game.data.title} ("${game.slug}")`);
}
if (!game.data.thumbnail) {
throw new Error(`Missing "thumbnail" for published game ${game.data.title} ("${game.slug}")`);
}
if (game.data.tags.length == 0) {
throw new Error(`Missing "tags" for published game ${game.data.title} ("${game.slug}")`);
}
}
const { Content } = await game.render();
---

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

View file

@ -20,21 +20,7 @@ export const getStaticPaths: GetStaticPaths = async () => {
const story = Astro.props;
const readingTime = getReadingTime(story.body);
if (!story.data.isDraft) {
if (!story.data.wordCount) {
throw new Error(`Missing "wordCount" for published story ${story.data.title} ("${story.slug}")`);
}
if (!story.data.pubDate) {
throw new Error(`Missing "pubDate" for published story ${story.data.title} ("${story.slug}")`);
}
if (!story.data.thumbnail) {
throw new Error(`Missing "thumbnail" for published story ${story.data.title} ("${story.slug}")`);
}
if (story.data.tags.length == 0) {
throw new Error(`Missing "tags" for published story ${story.data.title} ("${story.slug}")`);
}
}
if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 135) {
if (story.data.wordCount && Math.abs(story.data.wordCount - readingTime.words) >= 150) {
console.warn(
`"wordCount" differs greatly from actual word count for published story ${story.data.title} ("${story.slug}") ` +
`(expected ~${story.data.wordCount}, found ${readingTime.words})`,

View file

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

View file

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

View file

@ -2,14 +2,15 @@ import type { CollectionEntry } from "astro:content";
import { DEFAULT_LANG, type Lang } from "../content/config";
export function getUsernameForLang(user: CollectionEntry<"users">, lang: Lang): string {
if (user.data.lang) {
if (user.data.lang[lang]) {
return user.data.lang[lang];
const { name } = user.data;
if (typeof name === "object") {
if (name[lang]) {
return name[lang];
}
throw new Error(`No "${lang}" translation for username "${user.data.name}" ("${user.id}")`);
throw new Error(`No "${lang}" translation for user "${user.id}"`);
}
if (lang !== DEFAULT_LANG) {
console.warn(`User "${user.data.name}" ("${user.id}") has no "lang" property for a "${lang}" translation`);
console.warn(`Name "${name}" for user "${user.id}" isn't translated to "${lang}"; using default`);
}
return user.data.name;
return name;
}

View file

@ -1,6 +1,6 @@
import type { CollectionEntry } from "astro:content";
import { ANONYMOUS_USER_ID as ID } from "../content/config";
import { ANONYMOUS_USER_ID } from "../content/config";
const ANONYMOUS_USER_ID: CollectionEntry<"users">["id"] = ID;
const ID: CollectionEntry<"users">["id"] = ANONYMOUS_USER_ID;
export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id == ANONYMOUS_USER_ID;
export const isAnonymousUser = (user: CollectionEntry<"users">) => user.id === ID;

View file

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