Add Mastodon links to new stories, add title texts, and improve tags

- Added Mastodon links to "Woofer Exploration" and "Rose's Binge"
- Add title texts to stories and games thumbnails on index, stories, games, and tag pages
- Add descriptions and related tags to tag pages
This commit is contained in:
Bad Manners 2024-07-23 17:02:49 -03:00
parent efcfce1e06
commit a713adc1ec
34 changed files with 493 additions and 239 deletions

13
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "gallery-badmanners-xyz", "name": "gallery-badmanners-xyz",
"version": "1.5.0", "version": "1.5.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gallery-badmanners-xyz", "name": "gallery-badmanners-xyz",
"version": "1.5.0", "version": "1.5.1",
"dependencies": { "dependencies": {
"@astrojs/check": "^0.5.10", "@astrojs/check": "^0.5.10",
"@astrojs/rss": "^4.0.5", "@astrojs/rss": "^4.0.5",
@ -15,7 +15,7 @@
"@tailwindcss/typography": "^0.5.12", "@tailwindcss/typography": "^0.5.12",
"@types/sanitize-html": "^2.11.0", "@types/sanitize-html": "^2.11.0",
"astro": "^4.5.16", "astro": "^4.5.16",
"astro-pagefind": "^1.5.0", "astro-pagefind": "^1.6.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"marked": "^12.0.1", "marked": "^12.0.1",
"pagefind": "^1.1.0", "pagefind": "^1.1.0",
@ -1800,9 +1800,10 @@
} }
}, },
"node_modules/astro-pagefind": { "node_modules/astro-pagefind": {
"version": "1.5.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/astro-pagefind/-/astro-pagefind-1.5.0.tgz", "resolved": "https://registry.npmjs.org/astro-pagefind/-/astro-pagefind-1.6.0.tgz",
"integrity": "sha512-CN7Afe9qW640U1qliCQMXN259Dl6VPUnl8FneLfKE7STV4HLiif4PbNZejylC6yXYyP6uyNVDHNOfJfBBW5h6A==", "integrity": "sha512-U/WuE0ktkZkoFJf6yopWO4DjIJ3+wrnopE2L3kUYiyqNTJpqmp13bFLR8gir6B+KzQ5dsXQtJZYTQtKJg1FxIA==",
"license": "MIT",
"dependencies": { "dependencies": {
"@pagefind/default-ui": "^1.0.3", "@pagefind/default-ui": "^1.0.3",
"pagefind": "^1.0.3", "pagefind": "^1.0.3",

View file

@ -1,7 +1,7 @@
{ {
"name": "gallery-badmanners-xyz", "name": "gallery-badmanners-xyz",
"type": "module", "type": "module",
"version": "1.5.0", "version": "1.5.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"start": "astro dev", "start": "astro dev",
@ -20,7 +20,7 @@
"@tailwindcss/typography": "^0.5.12", "@tailwindcss/typography": "^0.5.12",
"@types/sanitize-html": "^2.11.0", "@types/sanitize-html": "^2.11.0",
"astro": "^4.5.16", "astro": "^4.5.16",
"astro-pagefind": "^1.5.0", "astro-pagefind": "^1.6.0",
"github-slugger": "^2.0.0", "github-slugger": "^2.0.0",
"marked": "^12.0.1", "marked": "^12.0.1",
"pagefind": "^1.1.0", "pagefind": "^1.1.0",

View file

@ -1 +1,2 @@
ErrorDocument 404 /404.html ErrorDocument 404 /404.html
RedirectMatch 301 ^/stories(\/(index.html)?)?$ /stories/1/

View file

@ -53,7 +53,7 @@ const { instance, user, postId } = Astro.props;
<span class="mr-1" data-publish-date></span> <span class="mr-1" data-publish-date></span>
</a> </a>
</div> </div>
<div data-content class="prose-a:text-link prose prose-story my-1 dark:prose-invert prose-img:my-0"></div> <div data-content class="prose-a:text-link prose-story prose my-1 dark:prose-invert prose-img:my-0"></div>
<div class="ml-1 flex flex-row pb-2 pt-1"> <div class="ml-1 flex flex-row pb-2 pt-1">
<div class="flex" aria-label="Favorites"> <div class="flex" aria-label="Favorites">
<span data-favorites></span> <span data-favorites></span>

View file

@ -2,6 +2,6 @@
--- ---
<div class="prose-a:text-link prose prose-story max-w-none dark:prose-invert"> <div class="prose-a:text-link prose prose-bm max-w-none dark:prose-invert">
<slot /> <slot />
</div> </div>

View file

@ -185,10 +185,14 @@ const tagCategoriesCollection = defineCollection({
name: z.string(), name: z.string(),
index: z.number().int(), index: z.number().int(),
tags: z.array( tags: z.array(
z.union([ z.object({
z.string(), name: z.union([
z.record(lang, z.string()).refine((tag) => "eng" in tag, `object-formatted tag must have an "eng" key`), z.string(),
]), z.record(lang, z.string()).refine((tag) => "eng" in tag, `object-formatted tag must have an "eng" key`),
]),
description: z.string().optional(),
related: z.array(z.string()).optional(),
}),
), ),
}), }),
}); });

View file

@ -35,9 +35,9 @@ platforms:
posts: posts:
eka: https://aryion.com/g4/view/986297 eka: https://aryion.com/g4/view/986297
furaffinity: https://www.furaffinity.net/view/55712675/ furaffinity: https://www.furaffinity.net/view/55712675/
weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game
inkbunny: https://inkbunny.net/s/3262911 inkbunny: https://inkbunny.net/s/3262911
sofurry: https://www.sofurry.com/view/2109688 sofurry: https://www.sofurry.com/view/2109688
weasyl: https://www.weasyl.com/~badmanners/submissions/2356092/crossing-over-vore-game
mastodon: https://meow.social/@BadManners/112009918919441027 mastodon: https://meow.social/@BadManners/112009918919441027
tags: tags:
- oral vore - oral vore

View file

@ -10,6 +10,13 @@ description: |
A seemingly insatiable plushie makes short work of an entire town, and the last few people left will try their best to escape from their binge. A seemingly insatiable plushie makes short work of an entire town, and the last few people left will try their best to escape from their binge.
This is a commission for Dee! As a disclaimer, all characters, implied or otherwise, are adults. This is a commission for Dee! As a disclaimer, all characters, implied or otherwise, are adults.
posts:
eka: https://aryion.com/g4/view/1030875
furaffinity: https://www.furaffinity.net/view/57459926/
inkbunny: https://inkbunny.net/s/3377727
sofurry: https://www.sofurry.com/view/2156241
weasyl: https://www.weasyl.com/~badmanners/submissions/2396279/c-rose-s-binge
mastodon: https://meow.social/@BadManners/112827108738415986
tags: tags:
- oral vore - oral vore
- cock vore - cock vore

View file

@ -17,10 +17,10 @@ descriptionPlaintext: >
posts: posts:
eka: https://aryion.com/g4/view/994229 eka: https://aryion.com/g4/view/994229
furaffinity: https://www.furaffinity.net/view/56026627/ furaffinity: https://www.furaffinity.net/view/56026627/
weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
inkbunny: https://inkbunny.net/s/3283508 inkbunny: https://inkbunny.net/s/3283508
mastodon: https://meow.social/@BadManners/112157812554023271
sofurry: https://www.sofurry.com/view/2118138 sofurry: https://www.sofurry.com/view/2118138
weasyl: https://www.weasyl.com/~badmanners/submissions/2363560/tiny-accident
mastodon: https://meow.social/@BadManners/112157812554023271
tags: tags:
- anthro predator - anthro predator
- anthro prey - anthro prey

View file

@ -25,6 +25,7 @@ tags:
- willing predator - willing predator
- unwilling prey - unwilling prey
- flash fiction - flash fiction
- toki pona
summary: | summary: |
Lijan, a red nondescript mammal, makes his way through town to a new 'tomo moku' ("eating place"), expecting it to be a restaurant. They can't find the building among the similar looking ones, and spot a colorful and spooked bird covered in some liquid, who they ask for directions. Lijan, a red nondescript mammal, makes his way through town to a new 'tomo moku' ("eating place"), expecting it to be a restaurant. They can't find the building among the similar looking ones, and spot a colorful and spooked bird covered in some liquid, who they ask for directions.

View file

@ -14,6 +14,13 @@ descriptionPlaintext: >
The Director wakes up in the middle of the night to a little intruder, and decides to have some fun with him. The Director wakes up in the middle of the night to a little intruder, and decides to have some fun with him.
This was a gift for my friend Hans! This story is sort of a non-canon sequel to his game "Director Explorer" on Itch.io, which you should definitely try out if you haven't already! This was a gift for my friend Hans! This story is sort of a non-canon sequel to his game "Director Explorer" on Itch.io, which you should definitely try out if you haven't already!
posts:
eka: https://aryion.com/g4/view/1030872
furaffinity: https://www.furaffinity.net/view/57459921/
inkbunny: https://inkbunny.net/s/3377722
sofurry: https://www.sofurry.com/view/2156237
weasyl: https://www.weasyl.com/~badmanners/submissions/2396278/woofer-exploration
mastodon: https://meow.social/@BadManners/112827104119035982
tags: tags:
- Sam Brendan - Sam Brendan
- oral vore - oral vore

View file

@ -1,13 +1,19 @@
name: Body types name: Body types
index: 1 index: 1
tags: tags:
- anthro predator - name: anthro predator
- feral predator description: Scenarios where at least one of the predators is an anthropomorphic animal, i.e. generally regarded as a "furry".
- taur predator - name: feral predator
- eng: ambiguous predator description: Scenarios where at least one of the predators is an animal based on a real or mythological creature.
tok: sijelo pi jan pi wawa mute li ale - name: taur predator
- human prey 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.
- anthro prey - name: { eng: ambiguous predator, tok: sijelo pi jan pi wawa mute li ale }
- feral prey description: Scenarios where the body type of at least one of the predators is left ambiguous.
- eng: ambiguous prey - name: human prey
tok: sijelo pi jan pi wawa lili li ale description: Scenarios where at least one of the prey is a human person.
- name: anthro prey
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 }
description: Scenarios where the body type of at least one of the predators is left ambiguous.

View file

@ -1,15 +1,31 @@
name: Genders name: Genders
index: 2 index: 2
tags: tags:
- male predator - name: male predator
- trans male predator description: Scenarios where at least one of the predators is a man and/or male-presenting.
- female predator related:
- non-binary predator - trans male predator
- eng: ambiguous gender predator - name: trans male predator
tok: jan pi wawa mute li meli anu mije description: Scenarios where at least one of the predators is a man and/or male-presenting; more specifically, undergoing or or having undergone gender transition.
- male prey related:
- female prey - male predator
- trans female prey - name: female predator
- non-binary prey description: Scenarios where at least one of the predators is a woman and/or female-presenting.
- eng: ambiguous gender prey - name: non-binary predator
tok: jan pi wawa lili li meli anu mije 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 }
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.
- name: female prey
description: Scenarios where at least one of the prey is a woman and/or female-presenting.
related:
- trans female prey
- name: trans female prey
description: Scenarios where at least one of the prey is a woman and/or female-presenting; more specifically, undergoing or having undergone gender transition.
related:
- 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 }
description: Scenarios where the gender at least one of the predators is left ambiguous.

View file

@ -1,13 +1,25 @@
name: Other kinks name: Other kinks
index: 7 index: 7
tags: tags:
- hyper - name: hyper
- egg play description: Scenarios where a character has abnormally large features compared to their body, usually genitalia.
- transformation - name: egg play
- netorare description: Scenarios with sexual action involving eggs.
- sizeplay related:
- inflation - object vore
- daddy play - name: transformation
- BDSM description: Scenarios where a character changes body and/or species.
- dubcon - name: netorare
- plushie description: Scenarios involving a character cheating on their partner with or without consent, and/or cuckoldry.
- name: sizeplay
description: Scenarios where a character changes size.
- name: inflation
description: Scenarios where a character expands out with the excessive intake of fluids, including semen.
- name: daddy play
description: Scenarios where a character refers to another as their "daddy" in a kinky manner as part of a sexual situation.
- name: BDSM
description: Scenarios related to bondage, sadism, masochism, dominance, and/or submission in a kinky manner.
- name: dubcon
description: Scenarios where sexual consent is dubious.
- name: plushie
description: Scenarios with sexual action involving stuffed toys or plushie-like characters.

View file

@ -1,6 +1,9 @@
name: Recurring characters name: Recurring characters
index: 9 index: 9
tags: tags:
- Sam Brendan - name: Sam Brendan
- Beetle description: Content that includes my character and fursona [Sam, the mimic x maned wolf hybrid](https://badmanners.xyz/sam_brendan).
- Muno - name: Beetle
description: Content that includes my character [Beetle, the gryphon](https://booru.badmanners.xyz/index.php?q=post/view/3).
- name: Muno
description: Content that includes my character Muno, the snake.

View file

@ -1,9 +1,17 @@
name: Relative size name: Relative size
index: 3 index: 3
tags: tags:
- macro predator - name: macro predator
- micro prey description: Scenarios where at least one of the predators has a size/height one or more orders of magnitude larger than average.
- size difference - name: micro prey
- similar size description: Scenarios where at least one of the prey has a size/height one or more orders of magnitude smaller than average.
- same size - name: size difference
- smaller predator description: Scenarios where at least one of the predators has a notably larger size/height than one of their prey.
- name: similar size
description: Scenarios where at least one of the predators has a slightly larger size/height than one of their prey.
- name: same size
description: Scenarios where at least one of the predators has the same size/height as one of their prey, or their sizes are comparable.
- name: smaller predator
description: Scenarios where at least one of the predators has a notably smaller size/height than one of their prey.
related:
- role reversal

View file

@ -1,9 +1,25 @@
name: Sexual content name: Sexual content
index: 6 index: 6
tags: tags:
- nudity - name: nudity
- masturbation description: Scenarios where a character is nude and displaying their sexual features, without necessarily participating in a sexual situation.
- straight sex - name: masturbation
- gay sex description: Scenarios where a character sexually stimulates themselves.
- lesbian sex - name: straight sex
- orgy description: Scenarios where two or more characters of different genders participate in sexual activity.
related:
- orgy
- name: gay sex
description: Scenarios where two or more characters of the same gender, both men and/or male-presenting, participate in sexual activity.
related:
- orgy
- name: lesbian sex
description: Scenarios where two or more characters of the same gender, both women and/or female-presenting, participate in sexual activity.
related:
- orgy
- name: orgy
description: Scenarios where more than two characters participate in sexual activity freely.
related:
- straight sex
- gay sex
- lesbian sex

View file

@ -1,7 +1,11 @@
name: Type of content name: Type of content
index: 8 index: 8
tags: tags:
- request - name: request
- commission description: Stories made as part of someone else's request as a gift.
- eng: flash fiction - name: commission
tok: lipu lili description: Stories made as part of a commission to someone else.
- name: { eng: 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,13 +1,21 @@
name: Types of vore name: Types of vore
index: 0 index: 0
tags: tags:
- eng: oral vore - name: { eng: oral vore, tok: moku musi kepeken uta }
tok: moku musi kepeken uta description: Scenarios where prey are consumed by the predator through their mouth.
- anal vore - name: anal vore
- cock vore description: Scenarios where prey are consumed by the predator through their butt/anus.
- unbirth - name: cock vore
- tail vore description: Scenarios where prey are consumed by the predator through their penis.
- slit vore - name: unbirth
- sheath vore description: Scenarios where prey are consumed by the predator through their vagina/vulva.
- nipple vore - name: tail vore
- chest maw vore description: Scenarios where prey are consumed by the predator through an opening or mouth in their tail.
- name: slit vore
description: Scenarios where prey are consumed by the predator through a genital slit.
- name: sheath vore
description: Scenarios where prey are consumed by the predator through a genital sheath, generally around their penis.
- name: nipple vore
description: Scenarios where prey are consumed by the predator through their nipple/breast.
- name: chest maw vore
description: Scenarios where prey are consumed by the predator through a mouth/maw on their chest cavity.

View file

@ -1,20 +1,67 @@
name: Vore-related scenarios name: Vore-related scenarios
index: 5 index: 5
tags: tags:
- point of view - name: point of view
- long-term endo description: Scenarios where the narration takes the perspective of one of the characters, generally in first person.
- perma endo - name: long-term endo
- implied perma endo description: Scenarios where prey remains safely inside of their predator for an extended period of time.
- full tour related:
- implied full tour - perma endo
- regurgitation - implied perma endo
- implied regurgitation - name: perma endo
- prey transfer description: Scenarios where prey remains safely inside of their predator for an indefinite period of time.
- object vore related:
- role reversal - long-term endo
- nested vore - implied perma endo
- multiple prey - name: implied perma endo
- messy stomach description: Scenarios where prey is implied to remain safely inside of their predator for an indefinite period of time.
- hammerspace vore related:
- bladder vore - long-term endo
- soul vore - perma endo
- name: full tour
description: Scenarios where prey passes all the way through their predator's gastrointestinal system, entering through one end and exiting through the opposite one.
related:
- implied full tour
- name: implied full tour
description: Scenarios where prey is implied to pass all the way through their predator's gastrointestinal system, entering through one end and supposedly exiting through the opposite one.
related:
- full tour
- name: regurgitation
description: Scenarios where prey are explicitly let out through the same spot they entered their predator, specifically their mouth.
related:
- implied regurgitation
- oral vore
- name: implied regurgitation
description: Scenarios where prey are implied to have been let out through the same spot they entered their predator, specifically their mouth.
related:
- regurgitation
- oral vore
- name: prey transfer
description: Scenarios where prey are transferred from one predator into another, or transferred from one part of their predator to another.
- name: object vore
description: Scenarios where predators consume objects, alongside their prey or in place of them.
- name: role reversal
description: Scenarios where predators become prey and vice-versa, where dominant characters become prey, or where submissive characters become predators.
related:
- smaller predator
- name: nested vore
description: Scenarios where a predator becomes prey to another predator, or a prey consumes another prey.
related:
- multiple prey
- role reversal
- name: multiple prey
description: Scenarios where one predator consumes multiple prey at once.
- name: messy stomach
description: Scenarios where a prey visits a messy stomach, usually with semi-digested food inside.
related:
- oral vore
- name: hammerspace vore
description: Scenarios where a predator's body doesn't grow proportionally around the prey they consume, either at a substantially lower rate or not at all.
related:
- multiple prey
- name: bladder vore
description: Scenarios where prey consumed through the predator's urethra end up in their bladder.
related:
- cock vore
- name: soul vore
description: Scenarios where predators consume a soul instead of their prey's body.

View file

@ -1,13 +1,19 @@
name: Willingness name: Willingness
index: 4 index: 4
tags: tags:
- eng: willing predator - name: { eng: willing predator, tok: jan pi wawa mute li wile e moku musi }
tok: jan pi wawa mute li wile e moku musi description: Scenarios where at least one of the predators participates in vore willingly.
- semi-willing predator - name: semi-willing predator
- unwilling 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.
- asleep predator - name: unwilling predator
- willing prey description: Scenarios where at least one of the predators participates in vore unwillingly.
- semi-willing prey - name: asleep predator
- eng: unwilling prey description: Scenarios where at least one of the predators participates in vore while asleep.
tok: jan pi wawa lili li wile ala e moku musi - name: willing prey
- asleep prey 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 }
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

@ -151,6 +151,10 @@ export const UI_STRINGS: Record<string, TranslationRecord> = {
"game/platform_ios": { "game/platform_ios": {
eng: "iOS", eng: "iOS",
}, },
"game/warnings": {
eng: (platforms: string[], contentWarning: string) =>
`${(UI_STRINGS["game/platforms"]!.eng as (arg: string[]) => string)(platforms)}. ${contentWarning}`,
},
}; };
export function t(lang: Lang, stringOrSource: string | TranslationRecord, ...args: any[]): string { export function t(lang: Lang, stringOrSource: string | TranslationRecord, ...args: any[]): string {

View file

@ -22,12 +22,9 @@ const copyrightedCharacters = await formatCopyrightedCharacters(props.copyrighte
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft); // const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries( const categorizedTags = Object.fromEntries(
(await getCollection("tag-categories")).flatMap((category) => (await getCollection("tag-categories")).flatMap((category) =>
category.data.tags.map((tag) => { category.data.tags.map<[string, string]>(({ name }) =>
if (typeof tag === "string") { typeof name === "string" ? [name, name] : [t(DEFAULT_LANG, name as any), t(props.lang, name as any)],
return [tag, tag]; ),
}
return [t(DEFAULT_LANG, tag as any), t(props.lang, tag as any)];
}),
), ),
); );
const tags = props.tags.map<[string, string]>((tag) => { const tags = props.tags.map<[string, string]>((tag) => {

View file

@ -34,12 +34,9 @@ const relatedStories = (await getEntries(props.relatedStories)).filter((story) =
// const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft); // const relatedGames = (await getEntries(props.relatedGames)).filter((game) => !game.data.isDraft);
const categorizedTags = Object.fromEntries( const categorizedTags = Object.fromEntries(
(await getCollection("tag-categories")).flatMap((category) => (await getCollection("tag-categories")).flatMap((category) =>
category.data.tags.map((tag) => { category.data.tags.map<[string, string]>(({ name }) =>
if (typeof tag === "string") { typeof name === "string" ? [name, name] : [t(DEFAULT_LANG, name as any), t(props.lang, name as any)],
return [tag, tag]; ),
}
return [t(DEFAULT_LANG, tag as any), t(props.lang, tag as any)];
}),
), ),
); );
const tags = props.tags.map<[string, string]>((tag) => { const tags = props.tags.map<[string, string]>((tag) => {

View file

@ -1,13 +1,12 @@
import type { APIRoute, GetStaticPaths } from "astro"; import type { APIRoute, GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content"; import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import { marked, type RendererApi } from "marked";
import { decode as tinyDecode } from "tiny-decode";
import type { Lang, Website } from "../../../content/config"; import type { Lang, Website } from "../../../content/config";
import { t } from "../../../i18n"; import { t } from "../../../i18n";
import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters"; import { formatCopyrightedCharacters } from "../../../utils/format_copyrighted_characters";
import { markdownToBbcode } from "../../../utils/markdown_to_bbcode";
interface ExportWebsiteInfo { interface ExportWebsiteInfo {
website: string; website: Website;
exportFormat: "bbcode" | "markdown"; exportFormat: "bbcode" | "markdown";
} }
@ -21,37 +20,6 @@ const WEBSITE_LIST = [
type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never; type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<{ website: infer K }> ? K : never;
//type ExportWebsiteName = typeof WEBSITE_LIST extends ReadonlyArray<[infer K, DescriptionExportFormat]> ? K : never;
const bbcodeRenderer: RendererApi = {
strong: (text) => `[b]${text}[/b]`,
em: (text) => `[i]${text}[/i]`,
codespan: (code) => code,
br: () => `\n\n`,
link: (href, _, text) => `[url=${href}]${text}[/url]`,
image: (href) => `[img]${href}[/img]`,
text: (text) => text,
paragraph: (text) => `\n${text}\n`,
list: (body, ordered) => (ordered ? `\n[ol]\n${body}[/ol]\n` : `\n[ul]\n${body}[/ul]\n`),
listitem: (text) => `[li]${text}[/li]\n`,
blockquote: (quote) => `\n[quote]${quote}[/quote]\n`,
code: (code) => `\n[code]${code}[/code]\n`,
heading: (heading) => `\n${heading}\n`,
table: (header, body) => `\n[table]\n${header}${body}[/table]\n`,
tablerow: (content) => `[tr]\n${content}[/tr]\n`,
tablecell: (content, { header }) => (header ? `[th]${content}[/th]\n` : `[td]${content}[/td]\n`),
hr: () => `\n===\n`,
del: () => {
throw new Error("Not supported by bbcodeRenderer: del");
},
html: () => {
throw new Error("Not supported by bbcodeRenderer: html");
},
checkbox: () => {
throw new Error("Not supported by bbcodeRenderer: checkbox");
},
};
function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string { function getUsernameForWebsite(user: CollectionEntry<"users">, website: Website): string {
const link = user.data.links[website]; const link = user.data.links[website];
if (link) { if (link) {
@ -147,7 +115,7 @@ function getLinkForUser(user: CollectionEntry<"users">, website: ExportWebsiteNa
if ("weasyl" in user.data.links) { if ("weasyl" in user.data.links) {
return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`; return `<!~${getUsernameForWebsite(user, "weasyl").replaceAll(" ", "")}>`;
} else if (isPreferredWebsite(user, "furaffinity")) { } else if (isPreferredWebsite(user, "furaffinity")) {
return `<fa:${getUsernameForWebsite(user, "furaffinity")}>`; return `<fa:${getUsernameForWebsite(user, "furaffinity").replaceAll("_", "")}>`;
} else if (isPreferredWebsite(user, "inkbunny")) { } else if (isPreferredWebsite(user, "inkbunny")) {
return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`; return `<ib:${getUsernameForWebsite(user, "inkbunny")}>`;
} else if (isPreferredWebsite(user, "sofurry")) { } else if (isPreferredWebsite(user, "sofurry")) {
@ -220,47 +188,40 @@ export const GET: APIRoute<Props, Params> = async ({ props: { story }, site }) =
const anonymousUser = await getEntry("users", "anonymous"); const anonymousUser = await getEntry("users", "anonymous");
const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang); const anonymousFallback = getNameForUser(anonymousUser, anonymousUser, lang);
const description: Record<ExportWebsiteName, string> = Object.fromEntries( const description = Object.fromEntries(
await Promise.all( WEBSITE_LIST.map<[ExportWebsiteName, string]>(({ website, exportFormat }) => {
WEBSITE_LIST.map(async ({ website, exportFormat }) => { const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback);
const u = (user: CollectionEntry<"users">) => getLinkForUser(user, website, anonymousFallback); const storyDescription = (
const storyDescription = ( [
[ story.data.description,
story.data.description, `*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`,
`*${t(lang, "story/warnings", story.data.wordCount || "???", story.data.contentWarning.trim())}*`, t(
t( lang,
lang, "export_story/writing",
"export_story/writing", authorsList.map((author) => u(author)),
authorsList.map((author) => u(author)), ),
), requester && t(lang, "export_story/request_for", u(requester)),
requester && t(lang, "export_story/request_for", u(requester)), commissioner && t(lang, "export_story/commissioned_by", u(commissioner)),
commissioner && t(lang, "export_story/commissioned_by", u(commissioner)), ...copyrightedCharacters.map(([user, characterList]) =>
...copyrightedCharacters.map(([user, characterList]) => characterList[0] == ""
characterList[0] == "" ? t(lang, "characters/all_characters_are_copyrighted_by", u(user))
? t(lang, "characters/all_characters_are_copyrighted_by", u(user)) : t(lang, "characters/characters_are_copyrighted_by", u(user), characterList),
: t(lang, "characters/characters_are_copyrighted_by", u(user), characterList), ),
), ].filter((data) => data) as string[]
].filter((data) => data) as string[] )
) .join("\n\n")
.join("\n\n") .replaceAll(
.replaceAll( /\[([^\]]+)\]\((\/[^\)]+)\)/g,
/\[([^\]]+)\]\((\/[^\)]+)\)/g, (_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`,
(_, group1, group2) => `[${group1}](${new URL(group2, site).toString()})`, );
); if (exportFormat === "bbcode") {
if (exportFormat === "bbcode") { return [website, markdownToBbcode(storyDescription).replaceAll(/\n\n\n+/g, "\n\n").trim()];
return [ } else if (exportFormat === "markdown") {
website, return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
tinyDecode(await marked.use({ renderer: bbcodeRenderer }).parse(storyDescription)) } else {
.replaceAll(/\n\n\n+/g, "\n\n") throw new Error(`Unhandled export format "${exportFormat}"`);
.trim(), }
]; })
}
if (exportFormat === "markdown") {
return [website, storyDescription.replaceAll(/\n\n\n+/g, "\n\n").trim()];
}
throw new Error(`Unhandled ExportFormat "${exportFormat}"`);
}),
),
); );
const storyHeader = const storyHeader =

View file

@ -84,7 +84,7 @@ export const GET: APIRoute = async ({ site }) => {
pubDate: toNoonUTCDate(data.pubDate!), pubDate: toNoonUTCDate(data.pubDate!),
link: `/games/${slug}`, link: `/games/${slug}`,
description: description:
`${t(data.lang, "game/platforms", data.platforms)}. ${data.contentWarning} ${data.descriptionPlaintext || data.description}` `${t(data.lang, "game/warnings", data.platforms, data.contentWarning)} ${data.descriptionPlaintext || data.description}`
.replaceAll(/[\n ]+/g, " ") .replaceAll(/[\n ]+/g, " ")
.trim(), .trim(),
categories: ["game"], categories: ["game"],
@ -104,8 +104,8 @@ export const GET: APIRoute = async ({ site }) => {
)}</p>` + )}</p>` +
`<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` + `<hr><p>${t(data.lang, "game/platforms", data.platforms)}</p>` +
`<hr><p><em>${data.contentWarning.trim()}</em></p>` + `<hr><p><em>${data.contentWarning.trim()}</em></p>` +
`<hr>${tinyDecode(await marked(body))}` + `<hr>${tinyDecode(marked.parse(body) as string)}` +
`<hr>${tinyDecode(await marked(data.description))}`, `<hr>${tinyDecode(marked.parse(data.description) as string)}`,
), ),
})), })),
), ),

View file

@ -2,6 +2,7 @@
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import GalleryLayout from "../layouts/GalleryLayout.astro"; import GalleryLayout from "../layouts/GalleryLayout.astro";
import { t } from "../i18n";
const games = (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)).sort( const games = (await getCollection("games", (game) => !game.data.isDraft && game.data.pubDate)).sort(
(a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime(), (a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime(),
@ -16,7 +17,11 @@ const games = (await getCollection("games", (game) => !game.data.isDraft && game
{ {
games.map((game) => ( games.map((game) => (
<li> <li>
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}> <a
class="text-link hover:underline focus:underline"
href={`/games/${game.slug}`}
title={t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim())}
>
{game.data.thumbnail ? ( {game.data.thumbnail ? (
<div class="flex aspect-[630/500] max-w-[288px] justify-center"> <div class="flex aspect-[630/500] max-w-[288px] justify-center">
<Image class="m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} /> <Image class="m-auto" src={game.data.thumbnail} alt={`Thumbnail for ${game.data.title}`} width={288} />

View file

@ -2,6 +2,7 @@
import { type CollectionEntry, getCollection } from "astro:content"; import { type CollectionEntry, getCollection } from "astro:content";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import GalleryLayout from "../layouts/GalleryLayout.astro"; import GalleryLayout from "../layouts/GalleryLayout.astro";
import { t } from "../i18n";
const MAX_ITEMS = 8; const MAX_ITEMS = 8;
@ -10,6 +11,7 @@ interface LatestItemsEntry {
thumbnail: CollectionEntry<"stories">["data"]["thumbnail"]; thumbnail: CollectionEntry<"stories">["data"]["thumbnail"];
href: string; href: string;
title: string; title: string;
altText: string;
pubDate: Date; pubDate: Date;
} }
@ -26,6 +28,7 @@ const latestItems: LatestItemsEntry[] = [
thumbnail: story.data.thumbnail, thumbnail: story.data.thumbnail,
href: `/stories/${story.slug}`, href: `/stories/${story.slug}`,
title: story.data.title, title: story.data.title,
altText: t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim()),
pubDate: story.data.pubDate!, pubDate: story.data.pubDate!,
})), })),
games.map<LatestItemsEntry>((game) => ({ games.map<LatestItemsEntry>((game) => ({
@ -33,6 +36,7 @@ const latestItems: LatestItemsEntry[] = [
thumbnail: game.data.thumbnail, thumbnail: game.data.thumbnail,
href: `/games/${game.slug}`, href: `/games/${game.slug}`,
title: game.data.title, title: game.data.title,
altText: t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim()),
pubDate: game.data.pubDate!, pubDate: game.data.pubDate!,
})), })),
] ]
@ -63,7 +67,7 @@ const latestItems: LatestItemsEntry[] = [
{ {
latestItems.map((entry) => ( latestItems.map((entry) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={entry.href}> <a class="text-link hover:underline focus:underline" href={entry.href} title={entry.altText}>
{entry.thumbnail ? ( {entry.thumbnail ? (
<div class="flex aspect-square max-w-[192px] justify-center"> <div class="flex aspect-square max-w-[192px] justify-center">
<Image class="m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} /> <Image class="m-auto" src={entry.thumbnail} alt={`Thumbnail for ${entry.title}`} width={192} />

View file

@ -4,6 +4,7 @@ import { Image } from "astro:assets";
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import GalleryLayout from "../../layouts/GalleryLayout.astro"; import GalleryLayout from "../../layouts/GalleryLayout.astro";
import type { CollectionEntry } from "astro:content"; import type { CollectionEntry } from "astro:content";
import { t } from "../../i18n";
type Props = { type Props = {
page: Page<CollectionEntry<"stories">>; page: Page<CollectionEntry<"stories">>;
@ -67,7 +68,11 @@ const totalPages = Math.ceil(page.total / page.size);
{ {
page.data.map((story) => ( page.data.map((story) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}> <a
class="text-link hover:underline focus:underline"
href={`/stories/${story.slug}`}
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
>
{story.data.thumbnail ? ( {story.data.thumbnail ? (
<div class="flex aspect-square max-w-[192px] justify-center"> <div class="flex aspect-square max-w-[192px] justify-center">
<Image <Image

View file

@ -2,6 +2,7 @@
import { getCollection } from "astro:content"; import { getCollection } from "astro:content";
import { slug } from "github-slugger"; import { slug } from "github-slugger";
import GalleryLayout from "../layouts/GalleryLayout.astro"; import GalleryLayout from "../layouts/GalleryLayout.astro";
import { markdownToPlaintext } from "../utils/markdown_to_plaintext";
const [stories, games, tagCategories] = await Promise.all([ const [stories, games, tagCategories] = await Promise.all([
getCollection("stories"), getCollection("stories"),
@ -30,35 +31,43 @@ const seriesCollection = await getCollection("series");
}); });
const uncategorizedTagsSet = new Set(tagsSet); const uncategorizedTagsSet = new Set(tagsSet);
const categorizedTags: Array<[string, string, string[]]> = tagCategories const categorizedTags: Array<[string, string, [string, string | undefined][]]> = await Promise.all(
.sort((a, b) => a.data.index - b.data.index) tagCategories
.map((category) => { .sort((a, b) => a.data.index - b.data.index)
const tagList = category.data.tags.map((tag) => (typeof tag === "string" ? tag : tag["eng"]!)); .map(async (category) => {
tagList.forEach((tag, index) => { const tagList = category.data.tags.map(({ name, description }) => {
if (index !== tagList.findLastIndex((val) => tag == val)) { description = description && markdownToPlaintext(description).replaceAll(/\n+/g, " ").trim();
throw new Error(`Duplicated tag "${tag}" found in multiple tag-categories`); return (typeof name === "string" ? [name, description] : [name["eng"]!, description]) as [
} string,
}); string | undefined,
return [ ];
category.data.name, });
category.id, tagList.forEach(([tag, _], index) => {
tagList.filter((tag) => { if (index !== tagList.findLastIndex(([otherTag, _]) => tag == otherTag)) {
if (draftOnlyTagsSet.has(tag)) { throw new Error(`Duplicated tag "${tag}" found in multiple tag-categories lists`);
console.log(`Omitting draft-only tag "${tag}"`);
return false;
} }
if (tagsSet.has(tag)) { });
uncategorizedTagsSet.delete(tag); return [
} category.data.name,
return true; category.id,
}), tagList.filter(([tag, _]) => {
]; if (draftOnlyTagsSet.has(tag)) {
}); console.log(`Omitting draft-only tag "${tag}"`);
return false;
}
if (tagsSet.has(tag)) {
uncategorizedTagsSet.delete(tag);
}
return true;
}),
];
}),
);
if (uncategorizedTagsSet.size > 0) { if (uncategorizedTagsSet.size > 0) {
const tagList = [...uncategorizedTagsSet]; const tagList = [...uncategorizedTagsSet];
console.log("The following tags have no category:", tagList); console.warn("The following tags have no category:", tagList);
categorizedTags.push(["Uncategorized tags", "others", tagList]); // categorizedTags.push(["Uncategorized tags", "others", tagList]);
} }
--- ---
@ -92,9 +101,9 @@ if (uncategorizedTagsSet.size > 0) {
{category} {category}
</h2> </h2>
<ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2"> <ul class="flex max-w-3xl flex-wrap gap-x-2 gap-y-2 px-2">
{tagList.map((tag) => ( {tagList.map(([tag, description]) => (
<li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white"> <li class="rounded-full bg-bm-300 px-3 py-1 text-sm text-black shadow-sm dark:bg-bm-600 dark:text-white">
<a class="hover:underline focus:underline" href={`/tags/${slug(tag)}`}> <a class="hover:underline focus:underline" href={`/tags/${slug(tag)}`} title={description}>
{tag} {tag}
</a> </a>
</li> </li>

View file

@ -2,11 +2,16 @@
import type { GetStaticPaths } from "astro"; import type { GetStaticPaths } from "astro";
import { Image } from "astro:assets"; import { Image } from "astro:assets";
import { type CollectionEntry, getCollection } from "astro:content"; import { type CollectionEntry, getCollection } from "astro:content";
import { Markdown } from "@astropub/md";
import { slug } from "github-slugger"; import { slug } from "github-slugger";
import GalleryLayout from "../../layouts/GalleryLayout.astro"; import GalleryLayout from "../../layouts/GalleryLayout.astro";
import Prose from "../../components/Prose.astro";
import { t } from "../../i18n";
type Props = { type Props = {
tag: string; tag: string;
description?: string;
related?: string[];
stories: CollectionEntry<"stories">[]; stories: CollectionEntry<"stories">[];
games: CollectionEntry<"games">[]; games: CollectionEntry<"games">[];
}; };
@ -16,10 +21,11 @@ type Params = {
}; };
export const getStaticPaths: GetStaticPaths = async () => { export const getStaticPaths: GetStaticPaths = async () => {
const [stories, games, series] = await Promise.all([ const [stories, games, series, tagCategories] = await Promise.all([
getCollection("stories"), getCollection("stories"),
getCollection("games"), getCollection("games"),
getCollection("series"), getCollection("series"),
getCollection("tag-categories"),
]); ]);
const seriesTags = new Set(series.map((s) => s.data.name)); const seriesTags = new Set(series.map((s) => s.data.name));
const tags = new Set<string>(); const tags = new Set<string>();
@ -33,12 +39,40 @@ export const getStaticPaths: GetStaticPaths = async () => {
tags.add(tag); tags.add(tag);
}); });
}); });
const tagDescriptions = Object.fromEntries(
tagCategories.flatMap((category) =>
category.data.tags.reduce(
(acc, { 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;
});
}
acc.push(
typeof name === "string" ? [name, { description, related }] : [name["eng"]!, { description, related }],
);
return acc;
},
[] as [string, { description: string | undefined; related: string[] | undefined }][],
),
),
);
return [...tags] return [...tags]
.filter((tag) => !seriesTags.has(tag)) .filter((tag) => !seriesTags.has(tag))
.map((tag) => ({ .map((tag) => ({
params: { slug: slug(tag) } satisfies Params, params: { slug: slug(tag) } satisfies Params,
props: { props: {
tag, tag,
description: tagDescriptions[tag].description,
related: tagDescriptions[tag].related,
stories: stories stories: stories
.filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag)) .filter((story) => !story.data.isDraft && story.data.pubDate && story.data.tags.includes(tag))
.sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()), .sort((a, b) => b.data.pubDate!.getTime() - a.data.pubDate!.getTime()),
@ -49,28 +83,43 @@ export const getStaticPaths: GetStaticPaths = async () => {
})); }));
}; };
const { tag, stories, games } = Astro.props; const { tag, description, stories, games, related } = Astro.props;
if (!description) {
console.warn(`Tag "${tag}" has no description!`);
}
const count = stories.length + games.length; const count = stories.length + games.length;
let tagDescription: string = ""; let totalWorksWithTag: string = "";
if (count == 1) { if (count == 1) {
if (stories.length == 1) { if (stories.length == 1) {
tagDescription = `One story tagged with "${tag}".`; totalWorksWithTag = `One story tagged with "${tag}".`;
} else if (games.length == 1) { } else if (games.length == 1) {
tagDescription = `One game tagged with "${tag}".`; totalWorksWithTag = `One game tagged with "${tag}".`;
} }
} else if (stories.length == 0) { } else if (stories.length == 0) {
tagDescription = `${games.length} games tagged with "${tag}".`; totalWorksWithTag = `${games.length} games tagged with "${tag}".`;
} else if (games.length == 0) { } else if (games.length == 0) {
tagDescription = `${stories.length} stories tagged with "${tag}".`; totalWorksWithTag = `${stories.length} stories tagged with "${tag}".`;
} else { } else {
tagDescription = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`; totalWorksWithTag = `${stories.length == 1 ? "One story" : `${stories.length} stories`} and ${games.length == 1 ? "one game" : `${games.length} games`} tagged with "${tag}".`;
} }
--- ---
<GalleryLayout pageTitle={`Works tagged "${tag}"`}> <GalleryLayout pageTitle={`Works tagged "${tag}"`}>
<meta slot="head-description" content={`Bad Manners || ${tagDescription || tag}`} property="og:description" /> <meta slot="head-description" content={`Bad Manners || ${totalWorksWithTag || tag}`} property="og:description" />
<h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{tag}"</h1> <h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{tag}"</h1>
{tagDescription ? <p class="my-4">{tagDescription}</p> : null} <div class="my-4">
<Prose>
{description ? <Markdown of={description} /> : null}
{
related?.length ? (
<p
set:html={`See also: ${related.map((relatedTag) => `<a href="/tags/${slug(relatedTag)}">${relatedTag}</a>`).join(", ")}.`}
/>
) : null
}
<p>{totalWorksWithTag}</p>
</Prose>
</div>
{ {
stories.length > 0 && ( stories.length > 0 && (
<section class="my-2" aria-labelledby="content-stories"> <section class="my-2" aria-labelledby="content-stories">
@ -80,7 +129,11 @@ if (count == 1) {
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{stories.map((story) => ( {stories.map((story) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/stories/${story.slug}`}> <a
class="text-link hover:underline focus:underline"
href={`/stories/${story.slug}`}
title={t(story.data.lang, "story/warnings", story.data.wordCount, story.data.contentWarning.trim())}
>
{story.data.thumbnail ? ( {story.data.thumbnail ? (
<div class="flex aspect-square max-w-[192px] justify-center"> <div class="flex aspect-square max-w-[192px] justify-center">
<Image <Image
@ -108,7 +161,11 @@ if (count == 1) {
<ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal"> <ul class="flex flex-wrap items-start justify-center gap-4 text-center md:justify-normal">
{games.map((game) => ( {games.map((game) => (
<li class="break-inside-avoid"> <li class="break-inside-avoid">
<a class="text-link hover:underline focus:underline" href={`/games/${game.slug}`}> <a
class="text-link hover:underline focus:underline"
href={`/games/${game.slug}`}
title={t(game.data.lang, "game/warnings", game.data.platforms, game.data.contentWarning.trim())}
>
{game.data.thumbnail ? ( {game.data.thumbnail ? (
<div class="flex aspect-[630/500] max-w-[192px] justify-center"> <div class="flex aspect-[630/500] max-w-[192px] justify-center">
<Image <Image

View file

@ -0,0 +1,33 @@
import { Marked, type RendererApi } from "marked";
import { decode } from "tiny-decode";
const renderer: RendererApi = {
strong: (text) => `[b]${text}[/b]`,
em: (text) => `[i]${text}[/i]`,
del: (text) => `[s]${text}[/s]`,
codespan: (code) => code,
br: () => `\n\n`,
link: (href, _, text) => `[url=${href}]${text}[/url]`,
image: (href) => `[img]${href}[/img]`,
text: (text) => text,
paragraph: (text) => `\n${text}\n`,
list: (body, ordered) => (ordered ? `\n[ol]\n${body}[/ol]\n` : `\n[ul]\n${body}[/ul]\n`),
listitem: (text) => `[li]${text}[/li]\n`,
blockquote: (quote) => `\n[quote]${quote}[/quote]\n`,
code: (code) => `\n[code]${code}[/code]\n`,
heading: (heading) => `\n${heading}\n`,
table: (header, body) => `\n[table]\n${header}${body}[/table]\n`,
tablerow: (content) => `[tr]\n${content}[/tr]\n`,
tablecell: (content, { header }) => (header ? `[th]${content}[/th]\n` : `[td]${content}[/td]\n`),
hr: () => `\n===\n`,
html: () => {
throw new Error("Not supported by BBCode: html");
},
checkbox: () => {
throw new Error("Not supported by BBCode: checkbox");
},
};
const bbcodeRenderer = new Marked({ renderer, async: false });
export const markdownToBbcode = (text: string) => decode((bbcodeRenderer.parse(text) as string).trim());

View file

@ -0,0 +1,35 @@
import { Marked, type RendererApi } from "marked";
import { decode } from "tiny-decode";
const renderer: RendererApi = {
strong: (text) => text,
em: (text) => `_${text}_`,
del: (text) => text,
codespan: (code) => code,
br: () => `\n\n`,
link: (_href, _title, text) => text,
text: (text) => text,
paragraph: (text) => `\n${text}\n`,
list: (body) => `\n${body}\n`,
listitem: (text) => `- ${text}\n`,
blockquote: (quote) => `\n> ${quote}\n`,
code: (code) => `\n${code}\n`,
heading: (heading) => `\n${heading}\n`,
table: (header, body) => `\n${header}\n---\n${body}\n`,
tablerow: (content) => `${content.slice(0, -3)}\n`,
tablecell: (content) => `${content} | `,
hr: () => `\n***\n`,
image: () => {
throw new Error("Not supported by plaintext: img");
},
html: () => {
throw new Error("Not supported by plaintext: html");
},
checkbox: () => {
throw new Error("Not supported by plaintext: checkbox");
},
};
const plaintextRenderer = new Marked({ renderer, async: false });
export const markdownToPlaintext = (text: string) => decode((plaintextRenderer.parse(text) as string).trim());

View file

@ -22,7 +22,7 @@ export default {
radial: "radial-gradient(var(--tw-gradient-stops))", radial: "radial-gradient(var(--tw-gradient-stops))",
}, },
typography: ({ theme }) => ({ typography: ({ theme }) => ({
story: { bm: {
css: { css: {
"--tw-prose-body": theme("colors.stone[800]"), "--tw-prose-body": theme("colors.stone[800]"),
"--tw-prose-headings": theme("colors.stone[900]"), "--tw-prose-headings": theme("colors.stone[900]"),