v3: the docs follow deno doc's grain — the index page was a hand-kept list of some 250 links into deno doc's output layout (./api/main.ts/index.html), policed by mod.test.ts; deno doc has no module index of its own beyond all_symbols.html, which lists every module by path with its summary. mod.ts is now a prose overview of the three parts and their layers, linking that list once, and mod.test.ts is gone. Re-exporting everything from mod.ts, as deno doc's tutorial does for a library, was tried and doesn't fit: export * drops the 18 routers (default exports) and makes 17 names ambiguous, and deno 2.9 renders export * as namespaces with the root's module doc and nested ones empty, losing every module doc. A router's page showed one line: its endpoint list sat on the default symbol, whose summary was cut at the colon introducing it; the list is now the module doc of all 18 routers, and the router's JSDoc says so. 22 summaries ended in a colon (the routers, BandcampPlayer, migration 0016, useDumpListSync, usePlaylistListSync) and read cut short wherever deno doc shows a first paragraph alone; they end in a full stop, doc-lint.ts now fails on a colon summary, and the README states the rule. No behaviour changes; 204 backend tests pass.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 50s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 21s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-28 19:28:24 +00:00
parent 563daa720c
commit 42e0356d21
27 changed files with 330 additions and 617 deletions

View File

@@ -167,8 +167,11 @@ of an exported interface; a comment that describes a declaration is always `/**
phrase. Parameters are named in the prose, in backticks. `{@link}` points at phrase. Parameters are named in the prose, in backticks. `{@link}` points at
symbols the file already has in scope. A function that can throw to its caller symbols the file already has in scope. A function that can throw to its caller
says so with `@throws`: for an `APIException`, the statuses and when. A router's says so with `@throws`: for an `APIException`, the statuses and when. A router's
JSDoc lists its endpoints, one line each: method and path, who may call it, what module doc lists its endpoints, one line each: method and path, who may call it,
it does; a page's names its route and who may see it. what it does; a page's JSDoc names its route and who may see it. The first
paragraph of a doc is its summary, which the reference shows alone in lists, so
it is a sentence that stands on its own: it never ends in a colon introducing
what follows.
In the frontend, every exported function states its return type, so the In the frontend, every exported function states its return type, so the
reference can show it: a component returns `React.JSX.Element` (or `| null`), a reference can show it: a component returns `React.JSX.Element` (or `| null`), a
@@ -180,12 +183,13 @@ rather than a default import of `react`, which `deno doc` can't follow.
`deno task doc` builds the HTML reference into `docs/` from every module of `deno task doc` builds the HTML reference into `docs/` from every module of
`api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`). `api/`, `shared/` and `src/` but the tests (and the generated `src/locales/`).
Its index page is the module doc of `mod.ts` at the root, which introduces the Its index page is the module doc of `mod.ts` at the root, which introduces the
repository's three parts and links every documented module (`mod.test.ts` checks repository's three parts and their layers in prose; `deno doc` builds the list
it), and the `docs` workflow publishes it on every push to `main` that touches of every module itself, grouped by path. The `docs` workflow publishes it on
them. `deno task doc:lint` checks the rules the linter can see, through every push to `main` that touches them. `deno task doc:lint` checks the rules
`doc-lint.ts`: `deno doc` doesn't document npm packages, so it takes a type the linter can see, and that no summary ends in a colon, through `doc-lint.ts`.
imported from one (react-hook-form's `FieldValues`, say) for a private one, and `deno doc` doesn't document npm packages, so it takes a type imported from one
those references alone are let through. (react-hook-form's `FieldValues`, say) for a private one, and those references
alone are let through.
Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is Each of the frontend's `components/`, `contexts/`, `hooks/` and `utils/` is
split into the same domain folders — `auth`, `category`, `chat`, `comment`, split into the same domain folders — `auth`, `category`, `chat`, `comment`,

View File

@@ -139,7 +139,8 @@ export const OG_SITE_NAME: string = Deno.env.get("GERBEUR_SITE_NAME") ||
export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚"; export const SITE_EMOJI: string = Deno.env.get("GERBEUR_SITE_EMOJI") || "🚚";
/** /**
* A Bandcamp playback path: * A Bandcamp playback path.
*
* - `"embed"` — the bandcamp.com iframe (cannot autoplay) * - `"embed"` — the bandcamp.com iframe (cannot autoplay)
* - `"native"` — resolve the page's mp3 streams and play them in the app's own * - `"native"` — resolve the page's mp3 streams and play them in the app's own
* player, which autoplays and supports albums as a queue * player, which autoplays and supports albums as a queue

View File

@@ -1,5 +1,6 @@
/** /**
* Indexes for the queries that ran as full scans: * Indexes for the queries that ran as full scans.
*
* - feeds page by created_at (newest first), a user's dumps and upvotes by * - feeds page by created_at (newest first), a user's dumps and upvotes by
* (user_id, created_at) — these replace the bare user_id indexes; * (user_id, created_at) — these replace the bare user_id indexes;
* - dump and playlist pages are looked up by slug; * - dump and playlist pages are looked up by slug;

View File

@@ -1,6 +1,11 @@
/** /**
* The attachment routes, for images pasted into Markdown bodies. * The attachment routes, for images pasted into Markdown bodies.
* *
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id; it is bound to a resource once a saved body
* links it.
* - `GET /api/attachments/:attachmentId` — public. Serves the image.
*
* @module * @module
*/ */
@@ -19,14 +24,7 @@ import {
validateImageUpload, validateImageUpload,
} from "../lib/upload.ts"; } from "../lib/upload.ts";
/** /** The router serving the endpoints the module doc lists. */
* The attachment routes, for images pasted into Markdown bodies:
*
* - `POST /api/attachments` — signed in. Stores an image (multipart `file`, at
* most 5 MB) and answers its id; it is bound to a resource once a saved body
* links it.
* - `GET /api/attachments/:attachmentId` — public. Serves the image.
*/
const router: Router = new Router(); const router: Router = new Router();
router.post("/api/attachments", authMiddleware, async (ctx) => { router.post("/api/attachments", authMiddleware, async (ctx) => {

View File

@@ -1,6 +1,11 @@
/** /**
* The avatar routes. * The avatar routes.
* *
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), announces it, and answers the caller's user.
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without
* one.
*
* @module * @module
*/ */
@@ -21,14 +26,7 @@ import {
validateImageUpload, validateImageUpload,
} from "../lib/upload.ts"; } from "../lib/upload.ts";
/** /** The router serving the endpoints the module doc lists. */
* The avatar routes:
*
* - `POST /api/avatars/me` — signed in. Replaces the caller's avatar (multipart
* `file`, at most 5 MB), announces it, and answers the caller's user.
* - `GET /api/avatars/:userId` — public. Serves a user's avatar; 404 without
* one.
*/
const router: Router = new Router(); const router: Router = new Router();
router.post("/api/avatars/me", authMiddleware, async (ctx) => { router.post("/api/avatars/me", authMiddleware, async (ctx) => {

View File

@@ -1,5 +1,8 @@
/** /**
* The Bandcamp route. * The Bandcamp route, for the native player.
*
* - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page
* to its streamable tracks, at play time; `force=1` bypasses the cache.
* *
* @module * @module
*/ */
@@ -9,12 +12,7 @@ import type { APIResponse } from "../../shared/api.ts";
import { resolveBandcamp } from "../remote/bandcamp-streams.ts"; import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
import type { Tralbum } from "../../shared/player.ts"; import type { Tralbum } from "../../shared/player.ts";
/** /** The router serving the endpoints the module doc lists. */
* The Bandcamp route, for the native player:
*
* - `GET /api/bandcamp/tracks?url=&force=` — public. Resolves a Bandcamp page
* to its streamable tracks, at play time; `force=1` bypasses the cache.
*/
const bandcampRouter: Router = new Router(); const bandcampRouter: Router = new Router();
// Public: it only reads a public page and returns URLs the same page already // Public: it only reads a public page and returns URLs the same page already

View File

@@ -1,6 +1,16 @@
/** /**
* The category routes. * The category routes.
* *
* - `GET /api/categories` — public. Answers every category, in order.
* - `GET /api/categories/:slug` — public. Answers one category.
* - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers
* a page of its dumps, newest first, with the caller's private ones.
* - `POST /api/categories` — permission `category:manage`. Creates a category.
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
* category.
* - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a
* category; its dumps stay.
*
* @module * @module
*/ */
@@ -31,19 +41,7 @@ import {
updateCategory, updateCategory,
} from "../services/category-service.ts"; } from "../services/category-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The category routes:
*
* - `GET /api/categories` — public. Answers every category, in order.
* - `GET /api/categories/:slug` — public. Answers one category.
* - `GET /api/categories/:slug/dumps?page=&limit=` — optional sign-in. Answers
* a page of its dumps, newest first, with the caller's private ones.
* - `POST /api/categories` — permission `category:manage`. Creates a category.
* - `PUT /api/categories/:id` — permission `category:manage`. Updates a
* category.
* - `DELETE /api/categories/:id` — permission `category:manage`. Deletes a
* category; its dumps stay.
*/
const router: Router = new Router({ prefix: "/api/categories" }); const router: Router = new Router({ prefix: "/api/categories" });
router.get("/", (ctx) => { router.get("/", (ctx) => {

View File

@@ -1,5 +1,13 @@
/** /**
* The chat history route. * The chat history route; messages are posted and edited over the socket (see
* routes/ws.ts).
*
* - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=` — signed in.
* Answers messages in ascending order: the most recent by default; with
* `before`, the page of older messages preceding that cursor ("load older");
* with `after`, the messages following it (resync after a reconnect). A
* cursor is an ISO `created_at` plus the message id to break ties; without
* `afterId` the `after` timestamp itself is included.
* *
* @module * @module
*/ */
@@ -13,17 +21,7 @@ import {
getRecentChatMessages, getRecentChatMessages,
} from "../services/chat-service.ts"; } from "../services/chat-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The chat history route; messages are posted and edited over the socket (see
* routes/ws.ts):
*
* - `GET /api/chat?limit=&before=&beforeId=&after=&afterId=` — signed in.
* Answers messages in ascending order: the most recent by default; with
* `before`, the page of older messages preceding that cursor ("load older");
* with `after`, the messages following it (resync after a reconnect). A
* cursor is an ISO `created_at` plus the message id to break ties; without
* `afterId` the `after` timestamp itself is included.
*/
const router: Router = new Router({ prefix: "/api/chat" }); const router: Router = new Router({ prefix: "/api/chat" });
router.get("/", authMiddleware, (ctx) => { router.get("/", authMiddleware, (ctx) => {

View File

@@ -1,5 +1,17 @@
/** /**
* The comment routes. * The comment routes. A private dump's comments exist only for its poster, and
* changes to them aren't broadcast.
*
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
* on a dump, oldest first.
* - `POST /api/dumps/:dumpId/comments` — signed in. Posts a comment, replying
* to `parentId` if given.
* - `PATCH /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Edits a comment.
* - `GET /api/comments/:commentId/likers?page=&limit=` — optional sign-in.
* Answers a page of the users who like a comment.
* - `DELETE /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Deletes a comment, leaving its place in the thread.
* *
* @module * @module
*/ */
@@ -37,21 +49,7 @@ import {
broadcastCommentUpdated, broadcastCommentUpdated,
} from "../services/ws-service.ts"; } from "../services/ws-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The comment routes. A private dump's comments exist only for its poster, and
* changes to them aren't broadcast:
*
* - `GET /api/dumps/:dumpId/comments` — optional sign-in. Answers every comment
* on a dump, oldest first.
* - `POST /api/dumps/:dumpId/comments` — signed in. Posts a comment, replying
* to `parentId` if given.
* - `PATCH /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Edits a comment.
* - `GET /api/comments/:commentId/likers?page=&limit=` — optional sign-in.
* Answers a page of the users who like a comment.
* - `DELETE /api/comments/:commentId` — signed in, as its author or with
* `comment:moderate`. Deletes a comment, leaving its place in the thread.
*/
const router: Router = new Router({ prefix: "/api" }); const router: Router = new Router({ prefix: "/api" });
router.get("/dumps/:dumpId/comments", async (ctx) => { router.get("/dumps/:dumpId/comments", async (ctx) => {

View File

@@ -1,5 +1,34 @@
/** /**
* The dump routes. * The dump routes. `:dumpId` takes an id or a slug; a private dump exists only
* for its poster.
*
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
* - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already
* pointing at the same thing as `url`, for the create form's duplicate hint.
* - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump.
* - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a
* page of the users who upvoted it.
* - `GET /api/dumps/:dumpId/related` — optional sign-in. Answers the dumps
* linked to or from it.
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
* playlists it appears in.
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
* dumps, newest first.
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
* `dump:moderate`. Replaces a file dump's upload (multipart).
* - `PUT /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Updates its title, comment, visibility, categories, and a
* link's URL.
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
* MB).
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Removes its custom thumbnail.
* - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or
* with `dump:moderate`. Fetches a link's preview again.
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Deletes it.
* *
* @module * @module
*/ */
@@ -44,38 +73,7 @@ import { getDumpVoters } from "../services/vote-service.ts";
import { getRelatedDumps } from "../services/backlink-service.ts"; import { getRelatedDumps } from "../services/backlink-service.ts";
import { getPlaylistsForDump } from "../services/playlist-service.ts"; import { getPlaylistsForDump } from "../services/playlist-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The dump routes. `:dumpId` takes an id or a slug; a private dump exists only
* for its poster:
*
* - `POST /api/dumps` — signed in. Posts a link (JSON body) or a file
* (multipart `file`, with `title`, `comment`, `isPrivate` and `categoryIds`).
* - `GET /api/dumps/by-url?url=` — optional sign-in. Answers the dumps already
* pointing at the same thing as `url`, for the create form's duplicate hint.
* - `GET /api/dumps/:dumpId` — optional sign-in. Answers a dump.
* - `GET /api/dumps/:dumpId/voters?page=&limit=` — optional sign-in. Answers a
* page of the users who upvoted it.
* - `GET /api/dumps/:dumpId/related` — optional sign-in. Answers the dumps
* linked to or from it.
* - `GET /api/dumps/:dumpId/playlists` — optional sign-in. Answers the
* playlists it appears in.
* - `GET /api/dumps?page=&limit=` — optional sign-in. Answers a page of the
* dumps, newest first.
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
* `dump:moderate`. Replaces a file dump's upload (multipart).
* - `PUT /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Updates its title, comment, visibility, categories, and a
* link's URL.
* - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5
* MB).
* - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with
* `dump:moderate`. Removes its custom thumbnail.
* - `POST /api/dumps/:dumpId/refresh-metadata` — signed in, as its poster or
* with `dump:moderate`. Fetches a link's preview again.
* - `DELETE /api/dumps/:dumpId` — signed in, as its poster or with
* `dump:moderate`. Deletes it.
*/
const router: Router = new Router({ prefix: "/api/dumps" }); const router: Router = new Router({ prefix: "/api/dumps" });
// Every route here takes a slug or an id; the services below them key on the // Every route here takes a slug or an id; the services below them key on the

View File

@@ -1,6 +1,10 @@
/** /**
* The route serving file dumps' uploads. * The route serving file dumps' uploads.
* *
* - `GET /api/files/:dumpId?token=` — public; a private dump's file needs its
* poster's media `token`. Serves the file, inline when that is safe and as a
* download otherwise, with byte ranges.
*
* @module * @module
*/ */
@@ -12,13 +16,7 @@ import { authenticateMedia } from "../lib/auth.ts";
import { serveFile } from "../lib/serve-file.ts"; import { serveFile } from "../lib/serve-file.ts";
import { dumpFileVersion, getDump } from "../services/dump-service.ts"; import { dumpFileVersion, getDump } from "../services/dump-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The route serving file dumps' uploads:
*
* - `GET /api/files/:dumpId?token=` — public; a private dump's file needs its
* poster's media `token`. Serves the file, inline when that is safe and as a
* download otherwise, with byte ranges.
*/
const router: Router = new Router({ prefix: "/api/files" }); const router: Router = new Router({ prefix: "/api/files" });
/** /**

View File

@@ -1,5 +1,20 @@
/** /**
* The follow routes. * The follow routes, all for what the caller follows.
*
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
* playlists they follow.
* - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a
* page of the public dumps of the users they follow, newest first, optionally
* in one category.
* - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in.
* Answers a page of the public dumps in the public playlists they follow,
* most recently added first, optionally in one category.
* - `POST /api/follows/users/:userId` — signed in. Follows a user.
* - `DELETE /api/follows/users/:userId` — signed in. Stops following a user.
* - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public
* playlist.
* - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a
* playlist.
* *
* @module * @module
*/ */
@@ -38,24 +53,7 @@ function resolveCategoryFilter(
/** The feed a filter naming no category yields. */ /** The feed a filter naming no category yields. */
const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false }; const EMPTY_FEED: Page<Dump> = { items: [], hasMore: false };
/** /** The router serving the endpoints the module doc lists. */
* The follow routes, all for what the caller follows:
*
* - `GET /api/follows/status` — signed in. Answers the ids of the users and
* playlists they follow.
* - `GET /api/follows/feed/users?page=&limit=&category=` — signed in. Answers a
* page of the public dumps of the users they follow, newest first, optionally
* in one category.
* - `GET /api/follows/feed/playlists?page=&limit=&category=` — signed in.
* Answers a page of the public dumps in the public playlists they follow,
* most recently added first, optionally in one category.
* - `POST /api/follows/users/:userId` — signed in. Follows a user.
* - `DELETE /api/follows/users/:userId` — signed in. Stops following a user.
* - `POST /api/follows/playlists/:playlistId` — signed in. Follows a public
* playlist.
* - `DELETE /api/follows/playlists/:playlistId` — signed in. Stops following a
* playlist.
*/
const router: Router = new Router({ prefix: "/api/follows" }); const router: Router = new Router({ prefix: "/api/follows" });
// Static routes first to prevent Oak matching "status"/"feed" as a :param // Static routes first to prevent Oak matching "status"/"feed" as a :param

View File

@@ -1,6 +1,10 @@
/** /**
* The invite routes. * The invite routes.
* *
* - `POST /api/invites` — signed in. Makes an invite and answers its token.
* - `GET /api/invites/:token` — public. Checks an invite is valid, before the
* register page shows its form; 404 when it isn't.
*
* @module * @module
*/ */
@@ -11,13 +15,7 @@ import type { InviteResponse } from "../../shared/user.ts";
import { type AuthContext, authMiddleware } from "../middleware/auth.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.ts";
import { createInvite, validateInvite } from "../services/invite-service.ts"; import { createInvite, validateInvite } from "../services/invite-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The invite routes:
*
* - `POST /api/invites` — signed in. Makes an invite and answers its token.
* - `GET /api/invites/:token` — public. Checks an invite is valid, before the
* register page shows its form; 404 when it isn't.
*/
const router: Router = new Router({ prefix: "/api/invites" }); const router: Router = new Router({ prefix: "/api/invites" });
router.post("/", authMiddleware, (ctx: AuthContext) => { router.post("/", authMiddleware, (ctx: AuthContext) => {

View File

@@ -1,5 +1,10 @@
/** /**
* The notification routes. * The notification routes, all for the caller's own notifications.
*
* - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them,
* newest first.
* - `POST /api/notifications/read-all` — signed in. Marks them all read.
* - `PATCH /api/notifications/:id/read` — signed in. Marks one read.
* *
* @module * @module
*/ */
@@ -20,14 +25,7 @@ import {
markOneRead, markOneRead,
} from "../services/notification-service.ts"; } from "../services/notification-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The notification routes, all for the caller's own notifications:
*
* - `GET /api/notifications?page=&limit=` — signed in. Answers a page of them,
* newest first.
* - `POST /api/notifications/read-all` — signed in. Marks them all read.
* - `PATCH /api/notifications/:id/read` — signed in. Marks one read.
*/
const router: Router = new Router({ prefix: "/api/notifications" }); const router: Router = new Router({ prefix: "/api/notifications" });
router.get("/", authMiddleware, (ctx: AuthContext) => { router.get("/", authMiddleware, (ctx: AuthContext) => {

View File

@@ -1,5 +1,26 @@
/** /**
* The playlist routes. * The playlist routes. `:playlistId` takes an id or a slug.
*
* - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each
* of the caller's playlists and whether it holds the dump.
* - `POST /api/playlists` — signed in. Creates a playlist.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
* with the dumps the caller may see in it; a private one only to its owner.
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Updates its title, description and visibility.
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Deletes it.
* - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
* or with `playlist:moderate`. Adds a dump at the top.
* - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
* or with `playlist:moderate`. Removes a dump.
* - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/image?token=` — public; a private
* playlist's cover needs its owner's media `token`. Serves its cover; 404
* without one.
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
* `playlist:moderate`. Reorders the dumps the caller can see in it.
* *
* @module * @module
*/ */
@@ -39,30 +60,7 @@ import {
validateImageUpload, validateImageUpload,
} from "../lib/upload.ts"; } from "../lib/upload.ts";
/** /** The router serving the endpoints the module doc lists. */
* The playlist routes. `:playlistId` takes an id or a slug:
*
* - `GET /api/playlists/by-dump/:dumpId/memberships` — signed in. Answers each
* of the caller's playlists and whether it holds the dump.
* - `POST /api/playlists` — signed in. Creates a playlist.
* - `GET /api/playlists/:playlistId` — optional sign-in. Answers a playlist
* with the dumps the caller may see in it; a private one only to its owner.
* - `PUT /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Updates its title, description and visibility.
* - `DELETE /api/playlists/:playlistId` — signed in, as its owner or with
* `playlist:moderate`. Deletes it.
* - `POST /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
* or with `playlist:moderate`. Adds a dump at the top.
* - `DELETE /api/playlists/:playlistId/dumps/:dumpId` — signed in, as its owner
* or with `playlist:moderate`. Removes a dump.
* - `POST /api/playlists/:playlistId/image` — signed in, as its owner or with
* `playlist:moderate`. Replaces its cover (multipart `file`, at most 5 MB).
* - `GET /api/playlists/:playlistId/image?token=` — public; a private
* playlist's cover needs its owner's media `token`. Serves its cover; 404
* without one.
* - `PUT /api/playlists/:playlistId/order` — signed in, as its owner or with
* `playlist:moderate`. Reorders the dumps the caller can see in it.
*/
const router: Router<AuthState> = new Router<AuthState>({ const router: Router<AuthState> = new Router<AuthState>({
prefix: "/api/playlists", prefix: "/api/playlists",
}); });

View File

@@ -1,6 +1,11 @@
/** /**
* The link preview routes. * The link preview routes.
* *
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and
* whether its page could be reached.
* - `GET /api/proxy-image?url=` — public. Serves a remote raster image through
* this site; 400 when it isn't one, 502 when its host can't be reached.
*
* @module * @module
*/ */
@@ -16,14 +21,7 @@ import {
import type { LinkPreview } from "../../shared/dump.ts"; import type { LinkPreview } from "../../shared/dump.ts";
import { authMiddleware } from "../middleware/auth.ts"; import { authMiddleware } from "../middleware/auth.ts";
/** /** The router serving the endpoints the module doc lists. */
* The link preview routes:
*
* - `GET /api/preview?url=` — signed in. Answers the preview of a link, and
* whether its page could be reached.
* - `GET /api/proxy-image?url=` — public. Serves a remote raster image through
* this site; 400 when it isn't one, 502 when its host can't be reached.
*/
const previewRouter: Router = new Router(); const previewRouter: Router = new Router();
// Signed-in only: it's the create form's preview, and an open one lets anyone // Signed-in only: it's the create form's preview, and an open one lets anyone

View File

@@ -1,6 +1,10 @@
/** /**
* The search route. * The search route.
* *
* - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the
* matching dumps with their total, and up to 20 matching users and playlists;
* private dumps and playlists only the caller's own.
*
* @module * @module
*/ */
@@ -13,13 +17,7 @@ import { searchPlaylists } from "../services/playlist-service.ts";
import type { APIResponse } from "../../shared/api.ts"; import type { APIResponse } from "../../shared/api.ts";
import type { SearchResults } from "../../shared/search.ts"; import type { SearchResults } from "../../shared/search.ts";
/** /** The router serving the endpoints the module doc lists. */
* The search route:
*
* - `GET /api/search?q=&page=&limit=` — optional sign-in. Answers a page of the
* matching dumps with their total, and up to 20 matching users and playlists;
* private dumps and playlists only the caller's own.
*/
const router: Router = new Router({ prefix: "/api/search" }); const router: Router = new Router({ prefix: "/api/search" });
router.get("/", async (ctx) => { router.get("/", async (ctx) => {

View File

@@ -1,6 +1,12 @@
/** /**
* The route serving dumps' thumbnails, generated with ffmpeg where needed. * The route serving dumps' thumbnails, generated with ffmpeg where needed.
* *
* - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither.
*
* @module * @module
*/ */
@@ -17,15 +23,7 @@ import { serveUploadedFile } from "../lib/upload.ts";
import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts";
import { authenticateMedia } from "../lib/auth.ts"; import { authenticateMedia } from "../lib/auth.ts";
/** /** The router serving the endpoints the module doc lists. */
* The route serving dumps' thumbnails:
*
* - `GET /api/thumbnails/:dumpId?token=&v=` — public; a private dump's
* thumbnail needs its poster's media `token`. Serves the dump's custom
* thumbnail, else one made from its upload: a still of a video, or a
* scaled-down copy of an image (the image itself when none can be made); 404
* when there is neither.
*/
const router: Router = new Router({ prefix: "/api/thumbnails" }); const router: Router = new Router({ prefix: "/api/thumbnails" });
/** /**

View File

@@ -1,5 +1,41 @@
/** /**
* The user and account routes. * The user and account routes. Public profiles never carry the password hash or
* the email.
*
* - `POST /api/users/register` — public, with a valid invite. Creates an
* account, sends the welcome email, and answers the user and their tokens.
* - `POST /api/users/login` — public. Answers the user and their tokens;
* rate-limited per account.
* - `GET /api/users/me` — signed in. Answers the caller's own user.
* - `GET /api/users/me/media-token` — signed in. Answers a media token, for a
* session that predates them.
* - `POST /api/users/request-password-reset` — public. Emails a reset link to
* the account with that address, if any; always answers 200.
* - `POST /api/users/reset-password` — public, with a reset token. Sets a new
* password.
* - `POST /api/users/me/change-password` — signed in, with the current
* password. Sets a new one, ending every other session, and answers fresh
* tokens.
* - `PATCH /api/users/me` — signed in. Updates the caller's profile fields:
* description and email.
* - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another
* user's role.
* - `GET /api/users/search?q=` — public. Answers up to 8 users whose name
* starts with `q`, for @mention suggestions.
* - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id.
* - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a
* page of the users they follow.
* - `GET /api/users/:username/followed-playlists?page=&limit=` — public.
* Answers a page of the public playlists they follow.
* - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in.
* Answers a page of their playlists, the private ones only to themselves.
* - `GET /api/users/:username/invitees` — public. Answers the tree of users
* they invited.
* - `GET /api/users/:username` — public. Answers a user's profile.
* - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a
* page of their dumps, the private ones only to themselves.
* - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a
* page of the dumps they upvoted, private ones only to themselves.
* *
* @module * @module
*/ */
@@ -70,45 +106,7 @@ import {
getFollowedUsersByUser, getFollowedUsersByUser,
} from "../services/follow-service.ts"; } from "../services/follow-service.ts";
/** /** The router serving the endpoints the module doc lists. */
* The user and account routes. Public profiles never carry the password hash or
* the email:
*
* - `POST /api/users/register` — public, with a valid invite. Creates an
* account, sends the welcome email, and answers the user and their tokens.
* - `POST /api/users/login` — public. Answers the user and their tokens;
* rate-limited per account.
* - `GET /api/users/me` — signed in. Answers the caller's own user.
* - `GET /api/users/me/media-token` — signed in. Answers a media token, for a
* session that predates them.
* - `POST /api/users/request-password-reset` — public. Emails a reset link to
* the account with that address, if any; always answers 200.
* - `POST /api/users/reset-password` — public, with a reset token. Sets a new
* password.
* - `POST /api/users/me/change-password` — signed in, with the current
* password. Sets a new one, ending every other session, and answers fresh
* tokens.
* - `PATCH /api/users/me` — signed in. Updates the caller's profile fields:
* description and email.
* - `PATCH /api/users/:userId/role` — permission `user:manage`. Sets another
* user's role.
* - `GET /api/users/search?q=` — public. Answers up to 8 users whose name
* starts with `q`, for @mention suggestions.
* - `GET /api/users/by-id/:userId` — public. Answers a user's profile by id.
* - `GET /api/users/:username/followed-users?page=&limit=` — public. Answers a
* page of the users they follow.
* - `GET /api/users/:username/followed-playlists?page=&limit=` — public.
* Answers a page of the public playlists they follow.
* - `GET /api/users/:username/playlists?page=&limit=` — optional sign-in.
* Answers a page of their playlists, the private ones only to themselves.
* - `GET /api/users/:username/invitees` — public. Answers the tree of users
* they invited.
* - `GET /api/users/:username` — public. Answers a user's profile.
* - `GET /api/users/:username/dumps?page=&limit=` — optional sign-in. Answers a
* page of their dumps, the private ones only to themselves.
* - `GET /api/users/:username/votes?page=&limit=` — optional sign-in. Answers a
* page of the dumps they upvoted, private ones only to themselves.
*/
const router: Router = new Router({ prefix: "/api/users" }); const router: Router = new Router({ prefix: "/api/users" });
/** Login attempts per account: a burst of 10, then one every 30s. */ /** Login attempts per account: a burst of 10, then one every 30s. */

View File

@@ -1,6 +1,12 @@
/** /**
* The WebSocket route, and the handling of what clients send over it. * The WebSocket route, and the handling of what clients send over it.
* *
* - `GET /ws?token=` — public; a session `token` signs the socket in, and an
* invalid one leaves it anonymous. Opens the socket that carries presence,
* live updates, votes, comment likes and the chat (see
* `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403
* from an origin not in {@link ALLOWED_ORIGINS}.
*
* @module * @module
*/ */
@@ -56,15 +62,7 @@ import type {
} from "../../shared/ws.ts"; } from "../../shared/ws.ts";
import type { User } from "../model/user.ts"; import type { User } from "../model/user.ts";
/** /** The router serving the endpoints the module doc lists. */
* The WebSocket route:
*
* - `GET /ws?token=` — public; a session `token` signs the socket in, and an
* invalid one leaves it anonymous. Opens the socket that carries presence,
* live updates, votes, comment likes and the chat (see
* `ClientToServerMessage` and `ServerToClientMessage` in shared/ws.ts); 403
* from an origin not in {@link ALLOWED_ORIGINS}.
*/
const router: Router = new Router(); const router: Router = new Router();
/** Checks whether a socket may be opened from `origin`. */ /** Checks whether a socket may be opened from `origin`. */

View File

@@ -1,5 +1,8 @@
/** /**
* The YouTube route. * The YouTube route, for the player's tracklist.
*
* - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id
* to its tracklist (titles and lengths).
* *
* @module * @module
*/ */
@@ -9,12 +12,7 @@ import type { APIResponse } from "../../shared/api.ts";
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts"; import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
import type { YouTubePlaylist } from "../../shared/player.ts"; import type { YouTubePlaylist } from "../../shared/player.ts";
/** /** The router serving the endpoints the module doc lists. */
* The YouTube route, for the player's tracklist:
*
* - `GET /api/youtube/playlist?list=` — public. Resolves a YouTube playlist id
* to its tracklist (titles and lengths).
*/
const youtubeRouter: Router = new Router(); const youtubeRouter: Router = new Router();
// Public, like /api/bandcamp/tracks: it only reads a public page. The client // Public, like /api/bandcamp/tracks: it only reads a public page. The client

View File

@@ -8,8 +8,8 @@
"i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1", "i18n:extract": "deno run -A npm:@lingui/cli extract --clean --overwrite --workers 1",
"i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1", "i18n:compile": "deno run -A npm:@lingui/cli compile --workers 1",
"start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts", "start": "deno task icons dist && deno run --env-file -A api/db/init.ts && deno run --env-file -A api/main.ts",
"test": "deno test -A api/ shared/ mod.test.ts", "test": "deno test -A api/ shared/",
"test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/ mod.test.ts", "test:coverage": "deno test -A --coverage=coverage/api --clean api/ shared/",
"test:web": "deno run -A npm:vitest run", "test:web": "deno run -A npm:vitest run",
"test:web:coverage": "deno run -A npm:vitest run --coverage", "test:web:coverage": "deno run -A npm:vitest run --coverage",
"doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')", "doc": "deno doc --html --name=gerbeur --output=docs mod.ts $(find api shared src \\( -name '*.ts' -o -name '*.tsx' \\) -not -path '*/test/*' -not -path 'src/locales/*')",

View File

@@ -7,6 +7,10 @@
* whose type the module imports from a package is let through, and counted; * whose type the module imports from a package is let through, and counted;
* every other diagnostic fails the run. * every other diagnostic fails the run.
* *
* It also checks what `deno doc --lint` doesn't: that every summary stands on
* its own. The reference shows a doc's first paragraph alone, in lists and
* above a symbol, so a first paragraph ending in a colon reads cut short.
*
* @module * @module
*/ */
@@ -62,8 +66,64 @@ function isPackageTypeRef(d: Diagnostic): boolean {
importedFromPackage(d.file, type); importedFromPackage(d.file, type);
} }
/** Returns the first paragraph of `doc`, which the reference shows alone. */
function summary(doc: string): string {
return doc.trim().split(/\n\s*\n|\n\s*(?:[-*]|\d+\.)\s|\n\s*```/)[0].trim();
}
/**
* Returns a diagnostic for every doc among `modules`, module docs included,
* whose summary ends in a colon.
*/
async function colonSummaries(modules: string[]): Promise<Diagnostic[]> {
const { stdout } = await new Deno.Command(Deno.execPath(), {
args: ["doc", "--json", ...modules],
stdout: "piped",
stderr: "null",
}).output();
const { nodes } = JSON.parse(new TextDecoder().decode(stdout)) as {
nodes: Record<string, unknown>;
};
const found: Diagnostic[] = [];
const cwd = `file://${Deno.cwd()}/`;
function visit(file: string, name: string, value: unknown) {
if (Array.isArray(value)) {
for (const item of value) visit(file, name, item);
return;
}
if (typeof value !== "object" || value === null) return;
const node = value as Record<string, unknown>;
const at = typeof node.name === "string" ? node.name : name;
for (const key of ["jsDoc", "module_doc"]) {
const doc = (node[key] as { doc?: string } | undefined)?.doc;
if (doc && summary(doc).endsWith(":")) {
const what = key === "module_doc" ? "module doc" : `doc of ${at}`;
const message = `the ${what} has a summary ending in a colon`;
found.push({
rule: "colon-summary",
message,
file,
text: `error[colon-summary]: ${message}\n --> ${file}`,
});
}
}
for (const [key, child] of Object.entries(node)) {
if (key !== "jsDoc" && key !== "module_doc" && key !== "imports") {
visit(file, at, child);
}
}
}
for (const [url, module] of Object.entries(nodes)) {
visit(url.replace(cwd, ""), "", module);
}
return found;
}
if (import.meta.main) { if (import.meta.main) {
const diagnostics = await lint(Deno.args); const diagnostics = [
...await lint(Deno.args),
...await colonSummaries(Deno.args),
];
const failures = diagnostics.filter((d) => !isPackageTypeRef(d)); const failures = diagnostics.filter((d) => !isPackageTypeRef(d));
for (const d of failures) console.error(d.text.trimEnd() + "\n"); for (const d of failures) console.error(d.text.trimEnd() + "\n");
const allowed = diagnostics.length - failures.length; const allowed = diagnostics.length - failures.length;

View File

@@ -1,22 +0,0 @@
import { assertEquals } from "@std/assert";
Deno.test("mod.ts, the docs' index page, links every documented module", async () => {
const doc = await Deno.readTextFile("mod.ts");
const linked = new Set(
[...doc.matchAll(/\]\(\.\/(\S+?\.tsx?)\/index\.html\)/g)].map((m) => m[1]),
);
const modules: string[] = [];
async function walk(dir: string) {
for await (const entry of Deno.readDir(dir)) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory) {
if (entry.name !== "test" && path !== "src/locales") await walk(path);
} else if (/\.tsx?$/.test(entry.name)) modules.push(path);
}
}
await walk("api");
await walk("shared");
await walk("src");
assertEquals(modules.filter((m) => !linked.has(m)), []);
assertEquals([...linked].filter((m) => !modules.includes(m)), []);
});

377
mod.ts
View File

@@ -1,356 +1,61 @@
/** /**
* # gerbeur
*
* A small invite-only social platform for sharing links and files. The * A small invite-only social platform for sharing links and files. The
* repository holds three parts: the backend in `api/`, the frontend in `src/`, * repository holds three parts: the backend in `api/`, the frontend in `src/`,
* and in `shared/` whatever crosses the wire between them. This reference * and in `shared/` whatever crosses the wire between them. This reference
* documents all three. * documents all three; [every module](./all_symbols.html) is listed by path,
* so they come grouped by part and layer.
* *
* ## Backend — `api/` * ## Backend — `api/`
* *
* An Oak server over SQLite that answers the API and the WebSocket, and serves * An Oak server over SQLite that answers the API and the WebSocket, and serves
* the built frontend. A request goes through a route, which parses it and * the built frontend. `api/main.ts` builds the application and listens. A
* answers. The route calls services, which hold the logic and every SQL query; * request goes through a route, which parses it and answers. The route calls
* they read rows through the model, and reach other sites only through * services, which hold the logic and every SQL query; they read rows through
* `remote/`. * the model, and reach other sites only through `remote/`. Each folder holds
* one kind of module, and each file is named after its domain:
* *
* - **Entry point** — builds the application and listens: * - `routes/` — the HTTP API. Each router's page lists its endpoints: method
* [main](./api/main.ts/index.html). * and path, who may call it, what it does.
* - **Routes** — the HTTP API; each router's page lists its endpoints: * - `middleware/` — what runs around every route: auth, errors, OG tags.
* [attachments](./api/routes/attachments.ts/index.html), * - `services/` — the logic and every SQL query, one module per domain.
* [avatars](./api/routes/avatars.ts/index.html), * - `model/` — per table, its row type, guard and mappers.
* [bandcamp](./api/routes/bandcamp.ts/index.html), * - `validators/` — the request-body guards.
* [categories](./api/routes/categories.ts/index.html), * - `remote/` — the only place that fetches from other sites; its `providers/`
* [chat](./api/routes/chat.ts/index.html), * build the previews of each kind of site.
* [comments](./api/routes/comments.ts/index.html), * - `lib/` — helpers that belong to no layer.
* [dumps](./api/routes/dumps.ts/index.html), * - `db/` — the handle, its creation, migration and startup housekeeping;
* [files](./api/routes/files.ts/index.html), * `db/migrations/` are applied in order, each once.
* [follows](./api/routes/follows.ts/index.html), * - `config.ts` — the settings read from the environment; `scripts/` — the
* [invites](./api/routes/invites.ts/index.html), * icon generator.
* [notifications](./api/routes/notifications.ts/index.html),
* [playlists](./api/routes/playlists.ts/index.html),
* [preview](./api/routes/preview.ts/index.html),
* [search](./api/routes/search.ts/index.html),
* [thumbnails](./api/routes/thumbnails.ts/index.html),
* [users](./api/routes/users.ts/index.html),
* [ws](./api/routes/ws.ts/index.html),
* [youtube](./api/routes/youtube.ts/index.html).
* - **Middleware** — what runs around every route:
* [auth](./api/middleware/auth.ts/index.html),
* [error](./api/middleware/error.ts/index.html),
* [og](./api/middleware/og.ts/index.html).
* - **Services** — the logic and every SQL query, one module per domain:
* [attachment-service](./api/services/attachment-service.ts/index.html),
* [backlink-service](./api/services/backlink-service.ts/index.html),
* [category-service](./api/services/category-service.ts/index.html),
* [chat-service](./api/services/chat-service.ts/index.html),
* [comment-like-service](./api/services/comment-like-service.ts/index.html),
* [comment-service](./api/services/comment-service.ts/index.html),
* [dump-service](./api/services/dump-service.ts/index.html),
* [email-service](./api/services/email-service.ts/index.html),
* [follow-service](./api/services/follow-service.ts/index.html),
* [invite-service](./api/services/invite-service.ts/index.html),
* [notification-service](./api/services/notification-service.ts/index.html),
* [password-reset-service](./api/services/password-reset-service.ts/index.html),
* [playlist-service](./api/services/playlist-service.ts/index.html),
* [rich-content-service](./api/services/rich-content-service.ts/index.html),
* [user-service](./api/services/user-service.ts/index.html),
* [vote-service](./api/services/vote-service.ts/index.html),
* [ws-service](./api/services/ws-service.ts/index.html).
* - **Model** — per table, its row type, guard and mappers:
* [auth](./api/model/auth.ts/index.html),
* [category](./api/model/category.ts/index.html),
* [chat](./api/model/chat.ts/index.html),
* [comment](./api/model/comment.ts/index.html),
* [dump](./api/model/dump.ts/index.html),
* [follow](./api/model/follow.ts/index.html),
* [invite](./api/model/invite.ts/index.html),
* [notification](./api/model/notification.ts/index.html),
* [password-reset](./api/model/password-reset.ts/index.html),
* [playlist](./api/model/playlist.ts/index.html),
* [user](./api/model/user.ts/index.html).
* - **Validators** — the request-body guards:
* [category](./api/validators/category.ts/index.html),
* [comment](./api/validators/comment.ts/index.html),
* [dump](./api/validators/dump.ts/index.html),
* [playlist](./api/validators/playlist.ts/index.html),
* [user](./api/validators/user.ts/index.html).
* - **Remote** — the only place that fetches from other sites:
* [bandcamp-streams](./api/remote/bandcamp-streams.ts/index.html),
* [bandcamp-tralbum](./api/remote/bandcamp-tralbum.ts/index.html),
* [fetch](./api/remote/fetch.ts/index.html),
* [html](./api/remote/html.ts/index.html),
* [image-proxy](./api/remote/image-proxy.ts/index.html),
* [public-address](./api/remote/public-address.ts/index.html),
* [twemoji](./api/remote/twemoji.ts/index.html),
* [youtube-playlist](./api/remote/youtube-playlist.ts/index.html).
* - **Link providers** — the previews of each kind of site:
* [bandcamp](./api/remote/providers/bandcamp.ts/index.html),
* [generic](./api/remote/providers/generic.ts/index.html),
* [provider](./api/remote/providers/provider.ts/index.html),
* [self](./api/remote/providers/self.ts/index.html),
* [soundcloud](./api/remote/providers/soundcloud.ts/index.html),
* [youtube](./api/remote/providers/youtube.ts/index.html).
* - **Lib** — helpers that belong to no layer:
* [api-exception](./api/lib/api-exception.ts/index.html),
* [auth](./api/lib/auth.ts/index.html),
* [canonical-url](./api/lib/canonical-url.ts/index.html),
* [index-html](./api/lib/index-html.ts/index.html),
* [jwt](./api/lib/jwt.ts/index.html),
* [pagination](./api/lib/pagination.ts/index.html),
* [permissions](./api/lib/permissions.ts/index.html),
* [playlist-members](./api/lib/playlist-members.ts/index.html),
* [rate-limit](./api/lib/rate-limit.ts/index.html),
* [reserved-slugs](./api/lib/reserved-slugs.ts/index.html),
* [self-url](./api/lib/self-url.ts/index.html),
* [serve-file](./api/lib/serve-file.ts/index.html),
* [site-icons](./api/lib/site-icons.ts/index.html),
* [slugify](./api/lib/slugify.ts/index.html),
* [static](./api/lib/static.ts/index.html),
* [upload](./api/lib/upload.ts/index.html).
* - **Database** — the handle, its creation, migration and startup
* housekeeping: [connection](./api/db/connection.ts/index.html),
* [init](./api/db/init.ts/index.html),
* [migrate](./api/db/migrate.ts/index.html),
* [startup](./api/db/startup.ts/index.html).
* - **Migrations** — applied in order, each once:
* [0001_comment_likes](./api/db/migrations/0001_comment_likes.ts/index.html),
* [0002_dump_thumbnail](./api/db/migrations/0002_dump_thumbnail.ts/index.html),
* [0003_dump_backlinks](./api/db/migrations/0003_dump_backlinks.ts/index.html),
* [0004_user_roles](./api/db/migrations/0004_user_roles.ts/index.html),
* [0005_drop_is_admin](./api/db/migrations/0005_drop_is_admin.ts/index.html),
* [0006_categories](./api/db/migrations/0006_categories.ts/index.html),
* [0007_password_reset_tokens](./api/db/migrations/0007_password_reset_tokens.ts/index.html),
* [0008_chat_messages](./api/db/migrations/0008_chat_messages.ts/index.html),
* [0009_chat_reply](./api/db/migrations/0009_chat_reply.ts/index.html),
* [0010_youtube_embed_start](./api/db/migrations/0010_youtube_embed_start.ts/index.html),
* [0011_split_favicon_thumbnail](./api/db/migrations/0011_split_favicon_thumbnail.ts/index.html),
* [0012_fix_favicon_reclassification](./api/db/migrations/0012_fix_favicon_reclassification.ts/index.html),
* [0013_dump_url_canonical](./api/db/migrations/0013_dump_url_canonical.ts/index.html),
* [0014_chat_read_state](./api/db/migrations/0014_chat_read_state.ts/index.html),
* [0015_token_revocation](./api/db/migrations/0015_token_revocation.ts/index.html),
* [0016_performance_indexes](./api/db/migrations/0016_performance_indexes.ts/index.html).
* - **Settings and scripts**: [config](./api/config.ts/index.html),
* [generate-icons](./api/scripts/generate-icons.ts/index.html).
* *
* ## Shared — `shared/` * ## Shared — `shared/`
* *
* The types and constants both halves use: the API envelope, each domain's * The types and constants both halves use, one module per domain: the API
* types and request bodies, the socket protocol, the validation limits and the * envelope, each domain's types and request bodies, the socket protocol, the
* role permissions. It imports nothing from either half. * validation limits and the role permissions. It imports nothing from either
* * half. The server sends `Date`s; the client reads the same types through
* Modules: [api](./shared/api.ts/index.html), * `Wire<T>`, with every `Date` an ISO string.
* [category](./shared/category.ts/index.html),
* [chat](./shared/chat.ts/index.html),
* [comment](./shared/comment.ts/index.html),
* [dump](./shared/dump.ts/index.html), [follow](./shared/follow.ts/index.html),
* [mention](./shared/mention.ts/index.html),
* [notification](./shared/notification.ts/index.html),
* [permissions](./shared/permissions.ts/index.html),
* [player](./shared/player.ts/index.html),
* [playlist](./shared/playlist.ts/index.html),
* [search](./shared/search.ts/index.html), [user](./shared/user.ts/index.html),
* [validation](./shared/validation.ts/index.html),
* [ws](./shared/ws.ts/index.html).
* *
* ## Frontend — `src/` * ## Frontend — `src/`
* *
* The React app, built with Vite and translated with Lingui. Each layer holds * The React app, built with Vite and translated with Lingui. `main.tsx` mounts
* one kind of module, and every layer but pages/ is split into the same domains * it, `App.tsx` holds the providers and the route table, `i18n.ts` the
* (auth, category, chat, comment, dump, editor, feed, form, layout, player, * language. A page reads the API and the socket through contexts and hooks, and
* playlist, profile, social, theme, ws, and shared for what belongs to no one
* domain). A page reads the API and the socket through contexts and hooks, and
* draws with components; utils hold what isn't React at all. * draws with components; utils hold what isn't React at all.
* *
* - **Entry point and app** — mounting, the providers and the route table, the * - `pages/` — one component per route, each naming it, and the two guards
* language: [App](./src/App.tsx/index.html), * that wrap the routes for the signed in and the signed out.
* [i18n](./src/i18n.ts/index.html), [main](./src/main.tsx/index.html). * - `config/` — settings read at startup.
* - **Pages** — one component per route, each naming it, and the two guards * - `contexts/` — a `FooContext` holding the state and its `FooProvider`.
* that wrap the routes for the signed in and the signed out: * - `hooks/` — every hook.
* [Dump](./src/pages/Dump.tsx/index.html), * - `components/` — only components.
* [DumpEdit](./src/pages/DumpEdit.tsx/index.html), * - `utils/` — every module that isn't React.
* [Index](./src/pages/Index.tsx/index.html), *
* [NotFound](./src/pages/NotFound.tsx/index.html), * Every layer but `pages/` and `config/` is split into the same domains (auth,
* [Notifications](./src/pages/Notifications.tsx/index.html), * category, chat, comment, dump, editor, feed, form, layout, player, playlist,
* [PlaylistDetail](./src/pages/PlaylistDetail.tsx/index.html), * profile, social, theme, ws, and shared for what belongs to no one domain), so
* [ResetPassword](./src/pages/ResetPassword.tsx/index.html), * the chat lives in `components/chat/`, `contexts/chat/`, `hooks/chat/` and
* [RestrictedGuest](./src/pages/RestrictedGuest.tsx/index.html), * `utils/chat/`.
* [RestrictedLoggedIn](./src/pages/RestrictedLoggedIn.tsx/index.html),
* [Search](./src/pages/Search.tsx/index.html),
* [UserDumps](./src/pages/UserDumps.tsx/index.html),
* [UserLogin](./src/pages/UserLogin.tsx/index.html),
* [UserPlaylists](./src/pages/UserPlaylists.tsx/index.html),
* [UserPublicProfile](./src/pages/UserPublicProfile.tsx/index.html),
* [UserRegister](./src/pages/UserRegister.tsx/index.html),
* [UserUpvoted](./src/pages/UserUpvoted.tsx/index.html).
* - **Config** — settings read at startup:
* [api](./src/config/api.ts/index.html),
* [feedTabs](./src/config/feedTabs.ts/index.html),
* [playerMode](./src/config/playerMode.ts/index.html).
* - **Contexts** — a FooContext holding the state and its FooProvider, by
* domain:
* - auth: [AuthContext](./src/contexts/auth/AuthContext.ts/index.html),
* [AuthProvider](./src/contexts/auth/AuthProvider.tsx/index.html).
* - category:
* [CategoriesContext](./src/contexts/category/CategoriesContext.ts/index.html),
* [CategoriesProvider](./src/contexts/category/CategoriesProvider.tsx/index.html).
* - chat: [ChatContext](./src/contexts/chat/ChatContext.ts/index.html),
* [ChatProvider](./src/contexts/chat/ChatProvider.tsx/index.html).
* - player:
* [PlayerContext](./src/contexts/player/PlayerContext.ts/index.html),
* [PlayerProvider](./src/contexts/player/PlayerProvider.tsx/index.html).
* - social:
* [FollowContext](./src/contexts/social/FollowContext.ts/index.html),
* [FollowProvider](./src/contexts/social/FollowProvider.tsx/index.html).
* - theme: [ThemeContext](./src/contexts/theme/ThemeContext.ts/index.html),
* [ThemeProvider](./src/contexts/theme/ThemeProvider.tsx/index.html).
* - ws:
* [AvatarOverrideContext](./src/contexts/ws/AvatarOverrideContext.ts/index.html),
* [WSContext](./src/contexts/ws/WSContext.ts/index.html),
* [WSProvider](./src/contexts/ws/WSProvider.tsx/index.html).
* - **Hooks** — every hook, by domain:
* - auth: [useAuth](./src/hooks/auth/useAuth.ts/index.html).
* - category:
* [useCategories](./src/hooks/category/useCategories.ts/index.html).
* - chat: [useChat](./src/hooks/chat/useChat.ts/index.html).
* - dump: [useDumpCardLink](./src/hooks/dump/useDumpCardLink.ts/index.html),
* [useDumpListSync](./src/hooks/dump/useDumpListSync.ts/index.html),
* [useUserDumpFeed](./src/hooks/dump/useUserDumpFeed.ts/index.html).
* - editor:
* [useEmojiTrigger](./src/hooks/editor/useEmojiTrigger.ts/index.html),
* [useMentionAutocomplete](./src/hooks/editor/useMentionAutocomplete.ts/index.html).
* - feed:
* [useDefaultFeedTab](./src/hooks/feed/useDefaultFeedTab.ts/index.html),
* [useFading](./src/hooks/feed/useFading.ts/index.html),
* [useFeedCache](./src/hooks/feed/useFeedCache.ts/index.html),
* [useInfiniteScroll](./src/hooks/feed/useInfiniteScroll.ts/index.html),
* [usePositionAwareSync](./src/hooks/feed/usePositionAwareSync.ts/index.html),
* [useScrollSave](./src/hooks/feed/useScrollSave.ts/index.html).
* - form: [useApiForm](./src/hooks/form/useApiForm.ts/index.html).
* - player:
* [usePlayRichContent](./src/hooks/player/usePlayRichContent.ts/index.html),
* [useYouTubePlaylist](./src/hooks/player/useYouTubePlaylist.ts/index.html).
* - playlist:
* [usePlaylistListSync](./src/hooks/playlist/usePlaylistListSync.ts/index.html).
* - shared:
* [useAnchoredFloat](./src/hooks/shared/useAnchoredFloat.ts/index.html),
* [useDocumentTitle](./src/hooks/shared/useDocumentTitle.ts/index.html),
* [useHeightTransition](./src/hooks/shared/useHeightTransition.ts/index.html),
* [useRoomBeside](./src/hooks/shared/useRoomBeside.ts/index.html),
* [useScrollLock](./src/hooks/shared/useScrollLock.ts/index.html),
* [useSoundsEnabled](./src/hooks/shared/useSoundsEnabled.ts/index.html),
* [useTabParam](./src/hooks/shared/useTabParam.ts/index.html).
* - social: [useFollows](./src/hooks/social/useFollows.ts/index.html).
* - theme: [useTheme](./src/hooks/theme/useTheme.ts/index.html).
* - ws: [useWS](./src/hooks/ws/useWS.ts/index.html).
* - **Components** — only components, by domain:
* - category:
* [CategoryManager](./src/components/category/CategoryManager.tsx/index.html),
* [CategorySelect](./src/components/category/CategorySelect.tsx/index.html),
* [CategorySwitcher](./src/components/category/CategorySwitcher.tsx/index.html).
* - chat: [ChatEditBox](./src/components/chat/ChatEditBox.tsx/index.html),
* [ChatMessageItem](./src/components/chat/ChatMessageItem.tsx/index.html),
* [ChatModal](./src/components/chat/ChatModal.tsx/index.html),
* [PendingItem](./src/components/chat/PendingItem.tsx/index.html),
* [ReplyRef](./src/components/chat/ReplyRef.tsx/index.html).
* - comment:
* [CommentForm](./src/components/comment/CommentForm.tsx/index.html),
* [CommentNode](./src/components/comment/CommentNode.tsx/index.html),
* [CommentThread](./src/components/comment/CommentThread.tsx/index.html).
* - dump: [DumpCard](./src/components/dump/DumpCard.tsx/index.html),
* [FilePreview](./src/components/dump/FilePreview.tsx/index.html),
* [JournalCard](./src/components/dump/JournalCard.tsx/index.html),
* [RichContentCard](./src/components/dump/RichContentCard.tsx/index.html),
* [DumpCreateModal](./src/components/dump/create/DumpCreateModal.tsx/index.html),
* [DuplicateNotice](./src/components/dump/create/DuplicateNotice.tsx/index.html),
* [LocalFilePreview](./src/components/dump/create/LocalFilePreview.tsx/index.html),
* [StepIndicator](./src/components/dump/create/StepIndicator.tsx/index.html),
* [TitleField](./src/components/dump/create/TitleField.tsx/index.html).
* - editor:
* [EmojiPickerPopup](./src/components/editor/EmojiPickerPopup.tsx/index.html),
* [Markdown](./src/components/editor/Markdown.tsx/index.html),
* [MentionDropdown](./src/components/editor/MentionDropdown.tsx/index.html),
* [TextEditor](./src/components/editor/TextEditor.tsx/index.html).
* - feed: [DumpFeed](./src/components/feed/DumpFeed.tsx/index.html),
* [FeedTabBar](./src/components/feed/FeedTabBar.tsx/index.html),
* [FollowedFeed](./src/components/feed/FollowedFeed.tsx/index.html),
* [JournalFeed](./src/components/feed/JournalFeed.tsx/index.html),
* [PresenceRow](./src/components/feed/PresenceRow.tsx/index.html).
* - form: [FileDropZone](./src/components/form/FileDropZone.tsx/index.html),
* [FileField](./src/components/form/FileField.tsx/index.html),
* [FormActions](./src/components/form/FormActions.tsx/index.html),
* [FormError](./src/components/form/FormError.tsx/index.html),
* [RichTextField](./src/components/form/RichTextField.tsx/index.html),
* [SegmentedField](./src/components/form/SegmentedField.tsx/index.html),
* [SubmitButton](./src/components/form/SubmitButton.tsx/index.html),
* [TextField](./src/components/form/TextField.tsx/index.html).
* - layout: [AppHeader](./src/components/layout/AppHeader.tsx/index.html),
* [ChatButton](./src/components/layout/ChatButton.tsx/index.html),
* [ChatFab](./src/components/layout/ChatFab.tsx/index.html),
* [DumpFab](./src/components/layout/DumpFab.tsx/index.html),
* [NotificationBell](./src/components/layout/NotificationBell.tsx/index.html),
* [PageError](./src/components/layout/PageError.tsx/index.html),
* [PageShell](./src/components/layout/PageShell.tsx/index.html),
* [SearchBar](./src/components/layout/SearchBar.tsx/index.html),
* [UserMenu](./src/components/layout/UserMenu.tsx/index.html).
* - player:
* [GlobalPlayer](./src/components/player/GlobalPlayer.tsx/index.html),
* [MediaPlayer](./src/components/player/MediaPlayer.tsx/index.html),
* [PlayerIcons](./src/components/player/PlayerIcons.tsx/index.html),
* [SeekBar](./src/components/player/SeekBar.tsx/index.html),
* [Waveform](./src/components/player/Waveform.tsx/index.html).
* - playlist:
* [AddToPlaylistModal](./src/components/playlist/AddToPlaylistModal.tsx/index.html),
* [NewPlaylistButton](./src/components/playlist/NewPlaylistButton.tsx/index.html),
* [PlaylistCard](./src/components/playlist/PlaylistCard.tsx/index.html),
* [PlaylistCreateForm](./src/components/playlist/PlaylistCreateForm.tsx/index.html),
* [PlaylistMembershipPanel](./src/components/playlist/PlaylistMembershipPanel.tsx/index.html).
* - profile:
* [ChangePasswordModal](./src/components/profile/ChangePasswordModal.tsx/index.html),
* [ProfileSubpageHeader](./src/components/profile/ProfileSubpageHeader.tsx/index.html).
* - shared: [Avatar](./src/components/shared/Avatar.tsx/index.html),
* [ConfirmModal](./src/components/shared/ConfirmModal.tsx/index.html),
* [CountedInput](./src/components/shared/CountedInput.tsx/index.html),
* [ErrorCard](./src/components/shared/ErrorCard.tsx/index.html),
* [ImagePicker](./src/components/shared/ImagePicker.tsx/index.html),
* [Modal](./src/components/shared/Modal.tsx/index.html),
* [TabBar](./src/components/shared/TabBar.tsx/index.html),
* [Thumbnail](./src/components/shared/Thumbnail.tsx/index.html),
* [ThumbnailPlaceholder](./src/components/shared/ThumbnailPlaceholder.tsx/index.html),
* [Tooltip](./src/components/shared/Tooltip.tsx/index.html).
* - social:
* [FollowButton](./src/components/social/FollowButton.tsx/index.html),
* [LikeButton](./src/components/social/LikeButton.tsx/index.html),
* [UserListPopover](./src/components/social/UserListPopover.tsx/index.html),
* [VoteButton](./src/components/social/VoteButton.tsx/index.html).
* - **Utils** — every module that isn't React, by domain:
* - auth: [permissions](./src/utils/auth/permissions.ts/index.html),
* [storage](./src/utils/auth/storage.ts/index.html).
* - chat: [draft](./src/utils/chat/draft.ts/index.html),
* [message](./src/utils/chat/message.ts/index.html),
* [text](./src/utils/chat/text.ts/index.html),
* [time](./src/utils/chat/time.ts/index.html).
* - dump: [draft](./src/utils/dump/draft.ts/index.html).
* - editor: [skinTone](./src/utils/editor/skinTone.ts/index.html),
* [text](./src/utils/editor/text.ts/index.html).
* - feed: [hotScore](./src/utils/feed/hotScore.ts/index.html),
* [journalLayout](./src/utils/feed/journalLayout.ts/index.html).
* - player: [bandcamp](./src/utils/player/bandcamp.ts/index.html),
* [duration](./src/utils/player/duration.ts/index.html),
* [streamSources](./src/utils/player/streamSources.ts/index.html),
* [volume](./src/utils/player/volume.ts/index.html),
* [waveform](./src/utils/player/waveform.ts/index.html),
* [youtube](./src/utils/player/youtube.ts/index.html).
* - shared: [api](./src/utils/shared/api.ts/index.html),
* [charCount](./src/utils/shared/charCount.ts/index.html),
* [deserialize](./src/utils/shared/deserialize.ts/index.html),
* [format](./src/utils/shared/format.ts/index.html),
* [proxyImage](./src/utils/shared/proxyImage.ts/index.html),
* [relativeTime](./src/utils/shared/relativeTime.ts/index.html),
* [sound](./src/utils/shared/sound.ts/index.html),
* [thumbnailTint](./src/utils/shared/thumbnailTint.ts/index.html),
* [urls](./src/utils/shared/urls.ts/index.html),
* [visited](./src/utils/shared/visited.ts/index.html).
* *
* @module * @module
*/ */

View File

@@ -25,7 +25,7 @@ export interface DumpListSyncOptions {
} }
/** /**
* Keeps the dump list set through `setDumps` in sync with real-time WS events: * Keeps the dump list set through `setDumps` in sync with real-time WS events.
* *
* - a dump deleted → filtered out * - a dump deleted → filtered out
* - a dump updated → updated in place, or removed if now private * - a dump updated → updated in place, or removed if now private

View File

@@ -32,7 +32,7 @@ export interface PlaylistListSyncOptions {
/** /**
* Keeps the playlist list set through `setPlaylists` in sync with real-time WS * Keeps the playlist list set through `setPlaylists` in sync with real-time WS
* events: * events.
* *
* - a playlist created → prepended (unless `noNewEntries`) * - a playlist created → prepended (unless `noNewEntries`)
* - a playlist updated → updated in place, or removed if now private * - a playlist updated → updated in place, or removed if now private