Add export-story script and draft for Tiny Accident

This commit is contained in:
Bad Manners 2024-03-24 22:48:07 -03:00
parent 7f7a62a391
commit 808f565e59
16 changed files with 678 additions and 15 deletions

View file

@ -1,11 +1,18 @@
# gallery.badmanners.xyz
## Development
### Export story for upload
```bash
npm run export-story -- --output-dir ~/Documents/TO_UPLOAD slug-for-selected-story
```
## Deployment
### Remote
```bash
npm install
npm run build
scp -r ./dist/* my-ssh-server:./gallery.badmanners.xyz/
```

239
package-lock.json generated
View file

@ -23,9 +23,12 @@
"typescript": "^5.4.2"
},
"devDependencies": {
"commander": "^12.0.0",
"fetch-retry": "^6.0.0",
"prettier": "^3.2.5",
"prettier-plugin-astro": "^0.13.0",
"prettier-plugin-tailwindcss": "^0.5.12"
"prettier-plugin-tailwindcss": "^0.5.12",
"tsx": "^4.7.1"
}
},
"node_modules/@alloc/quick-lru": {
@ -549,6 +552,30 @@
"node": ">=6.9.0"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"optional": true,
"peer": true,
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emmetio/abbreviation": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@emmetio/abbreviation/-/abbreviation-2.3.3.tgz",
@ -1223,6 +1250,34 @@
"node": ">=4"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.10.tgz",
"integrity": "sha512-PiaIWIoPvO6qm6t114ropMCagj6YAF24j9OkCA2mJDXFnlionEwhsBCJ8yek4aib575BI3OkART/90WsgHgLWw==",
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"optional": true,
"peer": true
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"optional": true,
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@ -1312,6 +1367,16 @@
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.10.tgz",
"integrity": "sha512-IfYcSBWE3hLpBg8+X2SEa8LVkJdJEkT2Ese2aaLs3ptGdVtABxndrMaxuFlQ1qdFf9Q5rDvDpxI3WwgvKFAsQA=="
},
"node_modules/@types/node": {
"version": "20.11.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.30.tgz",
"integrity": "sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw==",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/unist": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.2.tgz",
@ -1433,6 +1498,16 @@
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz",
"integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ansi-align": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz",
@ -2225,11 +2300,12 @@
}
},
"node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-12.0.0.tgz",
"integrity": "sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==",
"dev": true,
"engines": {
"node": ">= 6"
"node": ">=18"
}
},
"node_modules/common-ancestor-path": {
@ -2250,6 +2326,13 @@
"node": ">= 0.6"
}
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"optional": true,
"peer": true
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
@ -2636,6 +2719,12 @@
"reusify": "^1.0.4"
}
},
"node_modules/fetch-retry": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/fetch-retry/-/fetch-retry-6.0.0.tgz",
"integrity": "sha512-BUFj1aMubgib37I3v4q78fYo63Po7t4HUPTpQ6/QE6yK6cIQrP+W43FYToeTEyg5m2Y7eFUtijUuAv/PDlWuag==",
"dev": true
},
"node_modules/fill-range": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
@ -2771,6 +2860,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.7.3",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.3.tgz",
"integrity": "sha512-ZvkrzoUA0PQZM6fy6+/Hce561s+faD1rsNwhnO5FelNjyy7EMGJ3Rz1AQ8GYDWjhRs/7dBLOEJvhK8MiEJOAFg==",
"dev": true,
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/github-from-package": {
"version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@ -3526,6 +3627,13 @@
"node": ">=12"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"optional": true,
"peer": true
},
"node_modules/markdown-table": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.3.tgz",
@ -5504,6 +5612,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/restore-cursor": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-4.0.0.tgz",
@ -6374,6 +6491,14 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/sucrase/node_modules/commander": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"engines": {
"node": ">= 6"
}
},
"node_modules/suf-log": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/suf-log/-/suf-log-2.5.3.tgz",
@ -6546,6 +6671,67 @@
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"optional": true,
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/ts-node/node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"optional": true,
"peer": true
},
"node_modules/ts-node/node_modules/diff": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/tsconfck": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.0.3.tgz",
@ -6565,6 +6751,25 @@
}
}
},
"node_modules/tsx": {
"version": "4.7.1",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz",
"integrity": "sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==",
"dev": true,
"dependencies": {
"esbuild": "~0.19.10",
"get-tsconfig": "^4.7.2"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
@ -6613,6 +6818,13 @@
"semver": "^7.3.8"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"optional": true,
"peer": true
},
"node_modules/unherit": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/unherit/-/unherit-3.0.1.tgz",
@ -6798,6 +7010,13 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"optional": true,
"peer": true
},
"node_modules/vfile": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.1.tgz",
@ -7393,6 +7612,16 @@
"node": ">=8"
}
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"optional": true,
"peer": true,
"engines": {
"node": ">=6"
}
},
"node_modules/yocto-queue": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz",

View file

@ -8,7 +8,8 @@
"build": "astro check && astro build",
"preview": "astro preview",
"astro": "astro",
"prettier": "prettier . --write"
"prettier": "prettier . --write",
"export-story": "tsx scripts/export-story.ts"
},
"dependencies": {
"@astrojs/check": "^0.5.9",
@ -26,8 +27,11 @@
"typescript": "^5.4.2"
},
"devDependencies": {
"commander": "^12.0.0",
"fetch-retry": "^6.0.0",
"prettier": "^3.2.5",
"prettier-plugin-astro": "^0.13.0",
"prettier-plugin-tailwindcss": "^0.5.12"
"prettier-plugin-tailwindcss": "^0.5.12",
"tsx": "^4.7.1"
}
}

127
scripts/export-story.ts Normal file
View file

@ -0,0 +1,127 @@
import { type ChildProcess, exec, execSync } from "node:child_process";
import { readdir, mkdir, mkdtemp, writeFile, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join as pathJoin, normalize } from "node:path";
import { setTimeout } from "node:timers/promises";
import { program } from "commander";
import fetchRetryWrapper from "fetch-retry";
function getRTFStyles(rtfSource: string) {
const matches = rtfSource.matchAll(
/\\s(\d+)(?:\\sbasedon\d+)?\\snext\d+((?:\\[a-z0-9]+ ?)+)(?: ([A-Z][a-zA-Z ]*));/g,
);
let hasMatches = false;
const rtfStyles: Record<number | string, string> = {};
for (const [_, styleNumber, partialRTFStyle, styleName] of matches) {
hasMatches = true;
const rtfStyle = `\\s${styleNumber}${partialRTFStyle}`;
rtfStyles[Number.parseInt(styleNumber)] = rtfStyle;
rtfStyles[styleName] = rtfStyle;
}
if (!hasMatches) {
throw new Error("Couldn't find valid RTF styles!");
}
return rtfStyles;
}
const fetchRetry = fetchRetryWrapper(global.fetch);
async function exportStory(slug: string, options: { outputDir: string }) {
/* Check that outputDir is valid */
const outputDir = normalize(options.outputDir);
let files: string[];
try {
files = await readdir(outputDir);
} catch {
files = [];
console.log(`Created directory at ${await mkdir(outputDir, { recursive: true })}`);
}
if (files.length > 1) {
console.error(`Directory ${outputDir} is not empty!`);
process.exit(1);
}
/* Check if Astro development server needs to be spawned */
const healthcheckURL = `http://localhost:4321/healthcheck`;
let devServerProcess: ChildProcess | null = null;
try {
await fetchRetry(healthcheckURL, { retries: 3, retryDelay: 1000 });
} catch {
/* Spawn Astro dev server */
console.log("Starting Astro development server...");
devServerProcess = exec("npm run dev");
await setTimeout(2000);
try {
await fetchRetry(healthcheckURL, { retries: 5, retryDelay: 2000 });
} catch {
console.error("Astro dev server didn't respond in time!");
devServerProcess && devServerProcess.kill();
devServerProcess = null;
process.exit(1);
}
}
/* Get data (story, thumbnail, descriptions) from Astro development server */
let storyText = "";
try {
console.log("Getting data from Astro...");
const exportStoryURL = `http://localhost:4321/stories/export/story/${slug}`;
const exportThumbnailURL = `http://localhost:4321/stories/export/thumbnail/${slug}`;
const exportDescriptionURLs = (website: string) =>
`http://localhost:4321/stories/export/description/${website}/${slug}`;
await Promise.all(
["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"].map(async (website) => {
const description = await fetch(exportDescriptionURLs(website));
if (!description.ok) {
throw new Error(`Failed to get description for "${website}"`);
}
const descriptionExt = description.headers.get("Content-Type")?.startsWith("text/markdown") ? "md" : "txt";
return await writeFile(
pathJoin(outputDir, `description_${website}.${descriptionExt}`),
await description.text(),
);
}),
);
const thumbnail = await fetch(exportThumbnailURL);
if (!thumbnail.ok) {
throw new Error("Failed to get thumbnail");
}
const thumbnailExt = thumbnail.headers.get("Content-Type")?.startsWith("image/png") ? "png" : "jpg";
writeFile(pathJoin(outputDir, `thumbnail.${thumbnailExt}`), Buffer.from(await thumbnail.arrayBuffer()));
const story = await fetch(exportStoryURL);
if (!story.ok) {
throw new Error("Failed to get story");
}
storyText = await story.text();
writeFile(pathJoin(outputDir, `${slug}.txt`), storyText);
} finally {
if (devServerProcess) {
console.log("Shutting down the Astro development server...");
devServerProcess.kill();
devServerProcess = null;
}
}
/* Parse story into output formats */
console.log("Parsing story into output formats...");
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")}`);
const rtfText = await readFile(pathJoin(tempDir, "temp.rtf"), "utf-8");
const rtfStyles = getRTFStyles(rtfText);
await writeFile(
pathJoin(outputDir, `${slug}.rtf`),
rtfText.replaceAll(rtfStyles["Preformatted Text"], rtfStyles["Normal"]),
);
console.log("Success!");
return;
}
await program
.name("export-story")
.description("Generate and export formatted upload files for a story")
.argument("<story-slug>", `Slug portion of the story's URL (eg. "the-lost-of-the-marshes/chapter-1")`)
.option("-o, --output-dir <directory>", `Empty or inexistent directory path to export files to`)
.action(exportStory)
.parseAsync();

Binary file not shown.

After

(image error) Size: 21 KiB

View file

@ -39,7 +39,7 @@ const authorsArray = [authors].flat();
{lang === "tok" &&
(authorsArray.length > 1 ? (
<span>
lipu ni li tan ni:{" "}
lipu ni li tan jan ni:{" "}
{authorsArray.slice(0, authorsArray.length - 1).map((author) => (
<Fragment>
<UserComponent lang="tok" user={author} />

View file

@ -1,16 +1,20 @@
---
import { type CollectionEntry } from "astro:content";
import { type Lang } from "../content/config";
import { getEntry } from "astro:content";
type Props = {
lang: Lang;
user: CollectionEntry<"users">;
};
const { user, lang } = Astro.props;
let { user, lang } = Astro.props;
if (user.data.isAnonymous) {
user = await getEntry("users", "anonymous");
}
const username = user.data.nameLang[lang] || user.data.name;
let link: string | null = null;
if (!user.data.isAnonymous && user.data.preferredLink) {
if (user.data.preferredLink) {
if (user.data.preferredLink in user.data.links) {
const preferredLink = user.data.links[user.data.preferredLink] as string | [string, string];
if (typeof preferredLink === "string") {

View file

@ -17,6 +17,7 @@ tags:
- willing predator
- unwilling prey
- oral vore
- same size
- full tour
- point of view
copyrightedCharacters:

View file

@ -0,0 +1,169 @@
---
title: Tiny Accident
pubDate: 2024-03-25
isDraft: true
authors: bad-manners
wordCount: 2800
contentWarning: >
Contains: Non-fatal oral vore, with unwilling to willing anthro male rat predator, unwilling micro anthro male wolf prey. Also includes implied regurgitation, masturbation, sizeplay, and unaware micro groping.
thumbnail: /src/assets/thumbnails/bm_18_tiny_accident.png
description: |
Kolo's day at the airship is nearly over, but a tiny stalker will unwittingly make his evening quite eventful...
Finally got around to finishing a story ever since I worked on my game! I wanna get back into writing more stuff again, and this short story has finally broken my writer's block. My goal is to go back to working on commissions, but I feel I'm not quite in the headspace to tackle them just yet... Nevertheless, I hope you enjoy this!
tags:
- anthro predator
- anthro prey
- male predator
- male prey
- unwilling predator
- willing predator
- unwilling prey
- oral vore
- micro prey
- regurgitation
- masturbation
- sizeplay
---
Kolo sauntered back to his private cabin, wearing only flip-flops and a bath towel around his waist. The rat's pink tail swung low, arching up just inches from the floor grating. Another uneventful day on the airship, he thought gladly to himself and after hopping by the locker room showers when they were almost empty, he was ready to kick back and relax on his bunk bed until the dinner bell rang.
Traversing the narrow corridors of the living area with ease, he approached a reinforced steel door with '418' painted in white no different than the many other doors in this corridor and the ones above and below. Kolo punched in the code on the keypad, and pushed his brown shoulder against the cold, heavy metal as it opened inward with his weight.
"Jo, you in here?" The rodent called, noticing that the lights were already on.
He checked behind the partition where the bedroom, since Joaquim wasn't in the study room. The whole place was pretty cramped not much more than 100 square feet total , so there was nowhere for his roommate to be hiding. Kolo simply shut the reinforced door behind him, taking off his towel as he made his way to the lower bunk bed. He didn't need to feel embarrassed to be naked on his lonesome, after all.
The brown-furred rat flung himself with his back against the squeaky mattress, before taking his portable game console from its wall-mounted charger. He booted up his game to continue from where he left off but his round ear swiftly twitched.
"Hm? Jo?" Kolo looked around, thinking that he'd heard something close-by. But he couldn't see any signs of life in his vicinity. "...Must be the heating pipes." And he brought his console close to his face again.
Minutes passed, and Kolo was fully immersed in his game, shooting several monsters on his small screen. After a day of hard work, he didn't want to think about building heavy and complex machinery. He just wanted to cool down and enjoy himself even forgetting that he was stark naked. His lower bits were exposed and, unbeknownst to him, being ogled.
The rat didn't notice the other man on his bed, due to the diminutive size of his visitor. Tiny paws walked on the mattress, too light to make any noise on the creaky springs underneath. The floor felt hard for the micro, shaking with the smallest movements from the rodent focused on his game. The tiny intruder stealthily moved up the bed, ducking to walk underneath one of those large legs that had its knee raised. And past that furry overpass, there was nothing more in the way of the exposed sheath.
But Kolo wasn't aware in the slightest. Too focused on this particular boss that he'd already lost to a few times, his sensitive ears didn't pick up the slightest hint of something scurrying about on his bed until he felt a gentle brush to his sensitive testicles.
"Eek!"
The console on his paws nearly jumped all the way to the bottom of the top bunk. He managed to get a grip on it with his left paw instead of clumsily flinging it away while his right paw darted towards the strange feeling on his furry groins. His first instinct was to crush whatever cockroach had climbed onto his bed, but once reason kicked in, he managed to spare himself from a painful slap to his own balls.
He carefully cupped his testicles, trapping the micro groper between them and his fingers. It definitely wasn't a bug unless it was a very hairy and warm spider. Kolo could hear a distant voice coming from it, struggling against the paw prying it from Kolo's sac. Its shape was definitely that of an anthro, with arms and legs, head and tail all of which he was careful to handle. It only made him more confused, and he sat up while putting away his console and bringing his fist closer to his face.
With all of his attention on the micro shifting against his palm, the rat slowly opened his paw. The halogen light of the cabin peeked through his fingers, revealing a familiar rust-red and white lupine figure no taller than his index finger.
"J-JO?!" He shouted, loud enough to make the micro wolf wince in surprise too.
"K-Ko, hey..." Joaquim stammered, bashfully enough that his face was even redder than usual. "I can explain"
"What are you! Wh-Why are you! What the _fuck_ are you doing, being so small?!"
"K-Keep quiet, Ko...! I don't want Jason next door to hear us." The wolf pleaded.
"What? Why would you" Kolo stopped himself, his brows arching even higher than they already were. "Wait... That fucking shrink ray you had us make for you at Engineering. You told us it was for storage purposes...!"
"Y-Yeah, I, um, made some adjustments..." Joaquim raised his arm towards the pile of clothes at the corner of the room, where the barrel-less gun was sitting on. "I didn't mean to"
The rodent was quickly losing his patience. "You didn't mean to...what? Be a creepy pervert and sneak your way to my fucking dick?!"
Too nervous to lie his way out of this, the wolf simply looked away from the giant face and closed his lies. "I-I didn't think you'd feel or notice me..."
Kolo's face turned red with anger. "Ohhh you little shit...! Time to give you a fucking lesson you won't forget."
"W-Wait, Ko. I'm sorry"
Joaquim couldn't apologize in time for the furry floor to shift away from his butt. He thought that he'd fall, but the rat's skillful fingers grabbed him by his scuff. With his heart thumping loudly in his chest, the wolf was left dangling in front of the brown rat making Kolo realize that not only was he naked, but also erect.
"Tch. You fucking freak. This punishment oughta do it for ya."
Not realizing what he'd meant, Joaquim was surprised when Kolo opened his mouth wide enough even pushing his tongue out. It was a thrilling sight... One that he wouldn't gaze in horror for long, as he noticed the paw around him approaching that fleshy cave.
"Ko, s-stop it! Shit...!"
Thrash as he might, Joaquim was unable to break free as his digitigrade paws brushed against the wet tongue. He could smell the rat's breath in the warm air surrounding him not too unpleasant, but definitely unavoidable. To his horror, Kolo's fingers released him, and he began falling towards the back of his roommate's maw. He thought that this was it, and he'd end up being devoured by the rat... Only the jaws clamped shut before he slid too far, squishing and pinning him against the palate in complete darkness.
The rodent tilted his head forward, leaving the wolf mostly horizontal in his maw. He had no intention of swallowing Jo, but to simply teach him a lesson. Being so small, Kolo's tongue easily overwhelmed him, and the rat easily shoved the micro against the inside of his cheek. If Joaquim had any flavor, it was disguised by all that fur giving an odd mouthfeel. And when he noticed a smoother and saltier texture between his legs, Kolo grumbled even now, the tiny pervert was erect...!
"L-Lemme out! Ack!" The wolf begged, but the tongue dragged him against the back of Kolo's gums towards the other cheek.
Little did he know that his tiny shouts wouldn't even reach the rat's ears. Joaquim's plan had clearly backfired, and he could only ruminate on the choices that led him here. If only he hadn't come up with this idea after accidentally stumbling upon the naked rat the other day, or if only he hadn't pushed his luck by getting so close to that enticing, furry sheath... Now, he was surrounded by flesh and drool, smooshed around by the powerful yet careful muscle, listening to incessant squelches and subtle clicks.
For a couple of minutes, Kolo focused solely on his tiny catch. As long as he didn't think too hard that it was his usually taller roommate being jostled inside of his maw, it was easy to torment Jo without biting him or otherwise hurting him by accident. Actually, Kolo thought to himself, he might even be able to keep him there while playing his game, and let him go for dinnertime. Having to go to the dining hall while smelling like rat breath would teach the wolf never to try this again.
Joaquim, trying his best to grip onto the slippery tastebuds, found a small reprieve from the onslaught once the muscle below him settled down. Maybe Ko was ready to release him. It was about time...! As carefully as he could in the chaotic and soggy darkness, he planted his digitigrade feet on the tonsils for balance, bringing his paws up to try and climb his way out.
However, when the brown rat jerked his head forward to try and grab his game console again , Kolo realized too late that his tongue didn't have a good enough grip on the micro. He felt something push uncomfortably against his larynx, and his body reflexively swallowed, sending the obstruction down into his food pipe instead.
"O-Oh, no..." The rodent whispered to himself, desperation slowly settling in. He opened his maw wide, bringing his fingers inside to try and feel for any signs of the wolf. He ended up pushing them too far back, and coughed as he gagged on them. Once he realized that there was nothing out of place, he looked down at his belly. "Ohh, fuck...!"
Once he'd slipped into Kolo's throat, Joaquim started screaming his name but his roommate also wasn't able to hear as much. Like any piece of food, he was mercilessly squeezed down the esophagus. His slick fur slid easily through the pulsing tunnel, with no way to fight against gravity. It quickly got uncomfortably warm and damp, before he even ended up at his inevitable destination.
Mercifully, the rat's stomach was empty before the rust-red lupine had arrived. Jo was far from filling, and his body had to conform within the rugged walls that greeted him with an acrid smell. The organ was definitely less agreeable than Kolo's mouth, greeting him with a low-pitched whine that made his heart sink and his own stomach twist.
"K-Kolo!" The micro shouted out, fighting for space in the fleshy chamber. "You fucking _swallowed_ me!!"
"Oh shit... I-I accidentally swallowed you..." The rat's voice sounded much deeper as it arrived from every direction.
Joaquim tried his best to punch and kick at the walls restricting his movements. "Y-You motherfucker... Lemme out now!"
Kolo couldn't see anything different under his brown fur but he definitely felt something tiny wriggle in his insides, unlike anything he'd ever felt before.
"Sh-Shit... You're _really_ inside me..." He told himself, as the situation dawned on him. "I oughta let you out, b-but... Why is this making me _horny_...?"
"It's...wh-what?"
The wolf was suddenly tossed away, being smooshed snout-fist against a soft but slimy wall. Aside from the stomach's groans and a fast heartbeat, Jo could feel another thumping. It was strong enough to continually shake his surroundings, and as its pace increased, it became louder. The stifling air stewing in the organ alone made him nauseated, and the earthquakes only made his head spin harder. As he tried his best to find any balance, Joaquim was glad that neither of them had dinner yet or his would definitely have joined Kolo's.
Meanwhile, the rat was preoccupied with his own erection. He knew that it was wrong to take pleasure in his roommate's bleak situation but with no one else to judge him _but_ his roommate, Kolo's guilt only made him hornier. He could feel the subtle movements deep in his gut, where there shouldn't be any. Jo was definitely wiggling in there, and Kolo like he'd finally put the wolf in his place.
As his fully exposed pinkish cock dribbled more pre onto his paw, he tried to imagine what it was like inside. Was the wolf fighting for his life, yelling at Ko to stop...? Or was he enjoying this once-in-a-lifetime experience as well...? Maybe even thrusting his tiny cock or perhaps his entire tiny body against the hungry, churning walls?
"Mmmm, fuckk..." The rat slurred his words as his climax got closer. "Now you're just a snack, you little pervert..."
He continued to moan. Kolo wouldn't stop himself from fapping until he'd reached his peak Jo owed him as much, after what he tried to pull. And a couple of minutes in his sultry stomach wouldn't be too bad...probably. He was definitely moving in there, spreading his gastric folds, in Manners that regular food isn't capable of. But the tiny stimuli only made him hungrier, reminding him that he still had supper to look forward to. The rat clenched his teeth, imagining that he would keep him in there when he went to the dining hall and no one would be the wiser as he buried the helpless wolf between mouthfuls of dinner...
That thought pushed him past the edge, and he threw his head back while letting out a strained moan. Rope after rope of cum spewed out and onto his fingers and brown fur, and Kolo let go of his cock as the last of his seed began to drivel towards his sheath. He reached for his bath towel, using it to clean the white ooze as he recovered his breath.
Once his head had cleared up from the whole situation, he calmly got up from his bed and walked to his bulky suitcase on the floor. He unlocked it with one paw, while the other gingerly held his belly. Joaquim was nudging against a spot in his stomach, every now and then. Kolo was glad the last thing he needed was an investigation regarding his missing roommate.
He started dressing up, putting on some casual clothes. The micro's squirming meant that his erection wasn't going anywhere, but he managed to tuck it in his tight underwear and looser pants in such a way that his bulge should've been inconspicuous enough. Similarly, he slipped into shoes and a shirt. The movements inside of his gurgling belly seemed to urge him to go faster, but he didn't feel rushed. He just knew that Jo would be fine for a few extra minutes.
Once he was dressed, Kolo grabbed the shrink ray from the wolf's clothes pile as well as a pair of his pants. He left the private cabin and locked the door, and as he headed back to the locker room, he whistled a cheerful tune.
\*\*\*
"What took you so long?!" Jo shouted at the rat. Now at his normal size, he was standing completely naked in the showers, his rust-red fur completely covered in a greenish slime.
Kolo sputtered against the back of his paw. "Keep quiet, unless you wanna alert everyone else on this airship. I had to make sure there wasn't anyone else around to see me throwing you up."
"But what about me?! What if something bad had happened?"
"But nothing bad did, now did it?" He waved his finger in a circle. "Your fur looks fine to me. Did it burn or anything?"
"The acids?" The wolf moved a paw to his hip. "N-Not really, no..."
"See? I'm sure you woulda been fine even for a couple of hours."
The wolf couldn't believe the gall of his roommate. He simply watched incredulously at the rat who not only had swallowed him alive, but even masturbated at his expense...!
"H-Hey", Joaquim called out as the rodent began turning away. "Where are you going?"
"What? Do you need me to watch you shower? I'm going to the hall, it's almost dinnertime." He pointed at a bench. "I left you a pair of trousers here, once you're done."
"But aren't you afraid?"
"Afraid...? Of what?"
"That I'll tell someone what you did to me."
Kolo let out a soft chuckle. "You'll do no such thing. We both know you won't be stupid enough to admit that you stole company property for your own gain."
He raised his paw holding the shrink gun, before confidently spinning it around his finger.
Jo sighed. "A-And what are you going to do with it...?"
"Oh, this? I think I'll be confiscating it and I'll make sure you won't find it so soon, Jo."
The rat waved his arm as he walked away from the locker room for good. Left behind, the wolf took this moment to look down at his arms, and the fresh stomach slime still dripping from them. He'd really just been inside of Kolo's stomach, huh.
As he turned towards the shower head, Joaquim continued to conceal his hard-on with a paw, and he turned the faucet with the other.

View file

@ -1,3 +1,6 @@
name: Anonymous
nameLang:
eng: anonymous
tok: jan pi nimi ala
isAnonymous: true
links: {}

View file

@ -84,7 +84,7 @@ const tags = props.tags.map<[string, string]>((tag) => {
<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={props.lang === "eng" ? "Go to description" : props.lang === "tok" ? "o tawa e lipu lili" : null}
aria-label={props.lang === "eng" ? "Go to description" : props.lang === "tok" ? "o tawa e toki lipu" : null}
>
<svg viewBox="0 0 512 512" class="fill-current" aria-hidden="true">
<path
@ -227,7 +227,7 @@ const tags = props.tags.map<[string, string]>((tag) => {
}
<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">
{props.lang === "eng" ? "Description" : props.lang === "tok" ? "lipu lili" : null}
{props.lang === "eng" ? "Description" : props.lang === "tok" ? "toki lipu" : null}
</h2>
<Prose>
<Markdown of={props.description} />

10
src/pages/healthcheck.ts Normal file
View file

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

View file

@ -1,8 +1,8 @@
import type { APIRoute, GetStaticPaths } from "astro";
import { type APIRoute, type GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import { marked, type RendererApi } from "marked";
import he from "he";
import { type Website } from "../../../../content/config";
import { type Website } from "../../../../../content/config";
const WEBSITE_LIST = ["eka", "furaffinity", "inkbunny", "sofurry", "weasyl"] as const satisfies Website[];

View file

@ -0,0 +1,72 @@
import { type APIRoute, type GetStaticPaths } from "astro";
import { getCollection, getEntry, type CollectionEntry, getEntries } from "astro:content";
import { type Lang } from "../../../../content/config";
type Props = {
story: CollectionEntry<"stories">;
};
type Params = {
slug: CollectionEntry<"stories">["slug"];
};
export const getStaticPaths: GetStaticPaths = async () => {
if (import.meta.env.PROD) {
return [];
}
return (await getCollection("stories")).map((story) => ({
params: { slug: story.slug } satisfies Params,
props: { story } satisfies Props,
}));
};
function getNameForUser(user: CollectionEntry<"users">, anonymousUser: CollectionEntry<"users">, lang: Lang): string {
if (user.data.isAnonymous) {
return anonymousUser.data.nameLang[lang] || anonymousUser.data.name;
}
return user.data.nameLang[lang] || user.data.name;
}
export const GET: APIRoute<Props, Params> = async ({ props: { story } }) => {
const { lang } = story.data;
const anonymousUser = await getEntry("users", "anonymous");
const authorsNames = (await getEntries([story.data.authors].flat())).map((author) =>
getNameForUser(author, anonymousUser, lang),
);
const commissioner = story.data.commissioner && (await getEntry(story.data.commissioner));
const requester = story.data.requester && (await getEntry(story.data.requester));
let storyHeader = `${story.data.title}\n`;
if (lang === "eng") {
let authorsString = `by ${authorsNames[0]}`;
if (authorsNames.length > 2) {
authorsString += `, ${authorsNames.slice(1, authorsNames.length - 1).join(", ")}, and ${authorsNames[authorsNames.length - 1]}`;
} else if (authorsNames.length == 2) {
authorsString += ` and ${authorsNames[1]}`;
}
storyHeader +=
`${authorsString}\n` +
(commissioner ? `Commissioned by ${getNameForUser(commissioner, anonymousUser, lang)}\n` : "") +
(requester ? `Requested by ${getNameForUser(requester, anonymousUser, lang)}\n` : "");
} else if (lang === "tok") {
let authorsString = "lipu ni li tan ";
if (authorsNames.length > 1) {
authorsString += `jan ni: ${authorsNames.join(" en ")}`;
} else {
authorsString += authorsNames[0];
}
if (commissioner) {
throw new Error(`No "commissioner" handler for language "tok"`);
}
if (requester) {
throw new Error(`No "requester" handler for language "tok"`);
}
storyHeader += `${authorsString}\n`;
} else {
throw new Error(`Unknown language "${lang}"`);
}
const storyText = `${storyHeader}\n===\n\n${story.body.replaceAll("\\*", "*").replaceAll("\\=", "=")}`;
const headers = { "Content-Type": "text/plain; charset=utf-8" };
return new Response(`${storyText.replaceAll(/\n\n\n+/g, "\n\n").trim()}\n`, { headers });
};

View file

@ -0,0 +1,27 @@
import { type APIRoute, type GetStaticPaths } from "astro";
import { getCollection, type CollectionEntry } from "astro:content";
type Props = {
story: CollectionEntry<"stories">;
};
type Params = {
slug: CollectionEntry<"stories">["slug"];
};
export const getStaticPaths: GetStaticPaths = async () => {
if (import.meta.env.PROD) {
return [];
}
return (await getCollection("stories")).map((story) => ({
params: { slug: story.slug } satisfies Params,
props: { story } satisfies Props,
}));
};
export const GET: APIRoute<Props, Params> = async ({ props: { story }, redirect }) => {
if (!story.data.thumbnail) {
return new Response(null, { status: 404 });
}
return redirect(story.data.thumbnail.src);
};

View file

@ -0,0 +1,10 @@
---
import GalleryLayout from "../../layouts/GalleryLayout.astro";
const tag = "Digestion";
---
<GalleryLayout pageTitle={`Works tagged "${tag}"`}>
<h1 class="m-2 text-2xl font-semibold text-stone-800 dark:text-stone-100">Works tagged "{tag}"</h1>
<p class="my-4">No.</p>
</GalleryLayout>