From 4837ffcfeed4e773ae8a2269dc9afa63f7f281ab Mon Sep 17 00:00:00 2001 From: toridoriv Date: Mon, 16 Oct 2023 10:23:15 -0300 Subject: [PATCH] :building_construction: Remove old files to implement new structure --- app/catalog/catalog-languages.view.ts | 33 -- app/catalog/catalog-paginated.view.ts | 57 -- app/catalog/catalog-summary.view.ts | 48 -- app/catalog/catalog-tags.view.ts | 81 --- app/catalog/catalog.helpers.ts | 54 -- app/core/core.helpers.ts | 7 - app/core/database.ts | 57 -- app/core/deps.ts | 24 - app/core/endpoints/endpoint.api.ts | 219 ------- app/core/endpoints/endpoint.const.ts | 80 --- app/core/endpoints/endpoint.module.ts | 2 - app/core/endpoints/endpoint.utils.ts | 51 -- app/core/endpoints/endpoint.view.ts | 164 ------ app/core/fanfiction.ts | 145 ----- app/core/localization.ts | 482 --------------- app/core/logger.ts | 557 ------------------ app/core/mod.ts | 7 - app/core/tag.ts | 144 ----- app/core/utils.ts | 16 - app/login/login.middlewares.ts | 63 -- app/login/login.services.ts | 109 ---- app/login/login.view.ts | 43 -- app/login/logout.view.ts | 26 - app/main.ts | 1 - app/server/routers/router.api.ts | 11 - app/server/routers/router.assets.ts | 143 ----- app/server/routers/router.utils.ts | 68 --- app/server/routers/router.webpage.ts | 11 - app/server/server.config.ts | 64 -- app/server/server.middlewares.ts | 44 -- app/server/server.module.ts | 96 --- app/server/server.utils.ts | 60 -- app/views/catalog-paginated.hbs | 12 - app/views/catalog-summary.hbs | 13 - app/views/catalog-tags.hbs | 3 - app/views/error.hbs | 2 - app/views/layouts/main.hbs | 90 --- app/views/not-found.hbs | 2 - app/views/partials/fanfiction-card.hbs | 33 -- app/views/partials/login-modal.hbs | 32 - app/views/partials/tag-button.hbs | 12 - .../public/assets/android-chrome-192x192.png | Bin 6657 -> 0 bytes .../public/assets/android-chrome-512x512.png | Bin 23153 -> 0 bytes app/views/public/assets/apple-touch-icon.png | Bin 5921 -> 0 bytes app/views/public/assets/favicon-16x16.png | Bin 471 -> 0 bytes app/views/public/assets/favicon-32x32.png | Bin 919 -> 0 bytes app/views/public/assets/favicon.ico | Bin 15406 -> 0 bytes app/views/public/assets/robots.txt | 2 - app/views/public/assets/site.webmanifest | 20 - app/views/public/scripts/main.mjs | 10 - app/views/public/styles/main.css | 45 -- common/core/deps.ts | 3 - common/core/utility-types.ts | 324 ---------- 53 files changed, 3570 deletions(-) delete mode 100644 app/catalog/catalog-languages.view.ts delete mode 100644 app/catalog/catalog-paginated.view.ts delete mode 100644 app/catalog/catalog-summary.view.ts delete mode 100644 app/catalog/catalog-tags.view.ts delete mode 100644 app/catalog/catalog.helpers.ts delete mode 100644 app/core/core.helpers.ts delete mode 100644 app/core/database.ts delete mode 100644 app/core/deps.ts delete mode 100644 app/core/endpoints/endpoint.api.ts delete mode 100644 app/core/endpoints/endpoint.const.ts delete mode 100644 app/core/endpoints/endpoint.module.ts delete mode 100644 app/core/endpoints/endpoint.utils.ts delete mode 100644 app/core/endpoints/endpoint.view.ts delete mode 100644 app/core/fanfiction.ts delete mode 100644 app/core/localization.ts delete mode 100644 app/core/logger.ts delete mode 100644 app/core/mod.ts delete mode 100644 app/core/tag.ts delete mode 100644 app/core/utils.ts delete mode 100644 app/login/login.middlewares.ts delete mode 100644 app/login/login.services.ts delete mode 100644 app/login/login.view.ts delete mode 100644 app/login/logout.view.ts delete mode 100644 app/main.ts delete mode 100644 app/server/routers/router.api.ts delete mode 100644 app/server/routers/router.assets.ts delete mode 100644 app/server/routers/router.utils.ts delete mode 100644 app/server/routers/router.webpage.ts delete mode 100644 app/server/server.config.ts delete mode 100644 app/server/server.middlewares.ts delete mode 100644 app/server/server.module.ts delete mode 100644 app/server/server.utils.ts delete mode 100644 app/views/catalog-paginated.hbs delete mode 100644 app/views/catalog-summary.hbs delete mode 100644 app/views/catalog-tags.hbs delete mode 100644 app/views/error.hbs delete mode 100644 app/views/layouts/main.hbs delete mode 100644 app/views/not-found.hbs delete mode 100644 app/views/partials/fanfiction-card.hbs delete mode 100644 app/views/partials/login-modal.hbs delete mode 100644 app/views/partials/tag-button.hbs delete mode 100644 app/views/public/assets/android-chrome-192x192.png delete mode 100644 app/views/public/assets/android-chrome-512x512.png delete mode 100644 app/views/public/assets/apple-touch-icon.png delete mode 100644 app/views/public/assets/favicon-16x16.png delete mode 100644 app/views/public/assets/favicon-32x32.png delete mode 100644 app/views/public/assets/favicon.ico delete mode 100644 app/views/public/assets/robots.txt delete mode 100644 app/views/public/assets/site.webmanifest delete mode 100644 app/views/public/scripts/main.mjs delete mode 100644 app/views/public/styles/main.css delete mode 100644 common/core/deps.ts delete mode 100644 common/core/utility-types.ts diff --git a/app/catalog/catalog-languages.view.ts b/app/catalog/catalog-languages.view.ts deleted file mode 100644 index 734cab5..0000000 --- a/app/catalog/catalog-languages.view.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { - EndpointView, - getLanguageTag, - type LanguageCode, - sortTagsAlphabetically, - Tag, -} from "@common"; -import { z } from "@deps"; - -const CatalogItemSchema = z.custom(); - -export default EndpointView.init({ - method: "get", - path: "/languages-catalog", - view: "catalog-tags", - context: z.object({ - tags: z.array(CatalogItemSchema), - }), -}).registerHandler(async function main(_req, res) { - async function formatLanguageTag(code: LanguageCode) { - const total = await res.app.fanfics.countDocuments({ language_code: code }); - - return getLanguageTag(code, total); - } - const uniqueLanguages = await res.app.fanfics.distinct("language_code"); - const languageTags = await Promise.all( - uniqueLanguages.map(formatLanguageTag), - ); - - return this.renderOk(res, { - tags: sortTagsAlphabetically(languageTags), - }); -}); diff --git a/app/catalog/catalog-paginated.view.ts b/app/catalog/catalog-paginated.view.ts deleted file mode 100644 index fe1b577..0000000 --- a/app/catalog/catalog-paginated.view.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { EndpointView, LiteFanfictionOutput } from "@common"; -import { z } from "@deps"; - -const CatalogItemSchema = z.custom(); -const CatalogPageSchema = z.object({ - href: z.string(), - isActive: z.boolean().default(false), - index: z.number().int(), -}); - -const options = { - projection: { - _id: 0, - chapters: 0, - kind: 0, - }, - limit: 5, - sort: { created_at: -1 as const }, -}; - -export default EndpointView.init({ - method: "get", - path: "/fanfiction-catalog/pages/:page", - view: "catalog-paginated", - payload: { - params: z.object({ - page: z.coerce.number().int(), - }), - }, - context: z.object({ - fanfictions: z.array(CatalogItemSchema), - pages: z.array(CatalogPageSchema), - currentPage: z.number().int(), - }), -}).registerHandler(async function main(req, res) { - const total = await res.app.fanfics.countDocuments(); - const totalPages = Math.ceil(total / options.limit); - const currentPage = req.params.page; - const pages = Array.from( - { length: totalPages }, - (_, index) => - CatalogPageSchema.parse({ - href: this.path.replace(":page", String(index + 1)), - isActive: currentPage === index + 1, - index: index + 1, - }), - ); - const skip = currentPage === 1 - ? 0 - : currentPage * options.limit - options.limit; - const fanfictions = await res.app.fanfics - .find({}, options) - .skip(skip) - .toArray(); - - return this.renderOk(res, { fanfictions, pages, currentPage }); -}); diff --git a/app/catalog/catalog-summary.view.ts b/app/catalog/catalog-summary.view.ts deleted file mode 100644 index bd55ba7..0000000 --- a/app/catalog/catalog-summary.view.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { EndpointView, LiteFanfictionOutput } from "@common"; -import { z } from "@deps"; - -const CatalogSummarySchema = z.custom(); - -const limit = 3; -const projection = { - _id: 0, - chapters: 0, - kind: 0, -}; - -const recentlyAddedOptions = { - limit, - projection, - sort: { created_at: -1 as const }, -}; - -const recentlyUpdatedQuery = { - $expr: { $gt: ["$updated_at", "$created_at"] }, -}; - -const recentlyUpdatedOptions = { - limit, - projection, - sort: { - updated_at: -1 as const, - }, -}; - -export default EndpointView.init({ - method: "get", - path: "/", - view: "catalog-summary", - context: z.object({ - recentlyAdded: z.array(CatalogSummarySchema), - recentlyUpdated: z.array(CatalogSummarySchema), - }), -}).registerHandler(async function main(_req, res) { - const recentlyAdded = await res.app.fanfics - .find({}, recentlyAddedOptions) - .toArray(); - const recentlyUpdated = await res.app.fanfics - .find(recentlyUpdatedQuery, recentlyUpdatedOptions) - .toArray(); - - return this.renderOk(res, { recentlyAdded, recentlyUpdated }); -}); diff --git a/app/catalog/catalog-tags.view.ts b/app/catalog/catalog-tags.view.ts deleted file mode 100644 index bbbf4c8..0000000 --- a/app/catalog/catalog-tags.view.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - EndpointView, - getAuthorTag, - getFandomTag, - getLanguageTag, - getOriginTag, - getRelationshipTag, - LanguageCode, - sortTagsDescending, - Tag, -} from "@common"; -import { z } from "@deps"; - -const CatalogItemSchema = z.custom(); - -export default EndpointView.init({ - method: "get", - path: "/tags-catalog", - view: "catalog-tags", - context: z.object({ - tags: z.array(CatalogItemSchema), - }), -}).registerHandler(async function main(_req, res) { - async function formatLanguageTag(code: LanguageCode) { - const total = await res.app.fanfics.countDocuments({ language_code: code }); - - return getLanguageTag(code, total); - } - const uniqueLanguages = await res.app.fanfics.distinct("language_code"); - const languageTags = await Promise.all( - uniqueLanguages.map(formatLanguageTag), - ); - - async function formatSourceTag(source: string) { - const total = await res.app.fanfics.countDocuments({ source }); - - return getOriginTag(source, total); - } - const uniqueSources = await res.app.fanfics.distinct("source"); - const sourceTags = await Promise.all(uniqueSources.map(formatSourceTag)); - - async function formatFandomTag(fandom: string) { - const total = await res.app.fanfics.countDocuments({ fandom }); - - return getFandomTag(fandom, total); - } - const uniqueFandoms = await res.app.fanfics.distinct("fandom"); - const fandomTags = await Promise.all(uniqueFandoms.map(formatFandomTag)); - - async function formatRelationshipTag(relationship: string) { - const total = await res.app.fanfics.countDocuments({ relationship }); - - return getRelationshipTag(relationship, total); - } - const uniqueRelationships = await res.app.fanfics.distinct("relationship"); - const relationshipTags = await Promise.all( - uniqueRelationships.map(formatRelationshipTag), - ); - - async function formatAuthorTag(authorName: string) { - const total = await res.app.fanfics.countDocuments({ - "author.name": authorName, - }); - - return getAuthorTag(authorName, total); - } - const uniqueAuthors = await res.app.fanfics.distinct("author.name"); - const authorTags = await Promise.all( - uniqueAuthors.map(formatAuthorTag), - ); - - return this.renderOk(res, { - tags: sortTagsDescending([ - ...languageTags, - ...sourceTags, - ...fandomTags, - ...relationshipTags, - ...authorTags, - ]), - }); -}); diff --git a/app/catalog/catalog.helpers.ts b/app/catalog/catalog.helpers.ts deleted file mode 100644 index c441471..0000000 --- a/app/catalog/catalog.helpers.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { - getAuthorTag, - getFandomTag, - getLanguageTag, - getOriginTag, - getRelationshipTag, - type LiteFanfictionOutput, - type Tag, - type TextOutput, - type TextWithTranslationsOutput, -} from "@common"; - -export function getTags(fanfiction: LiteFanfictionOutput) { - const tags: Tag[] = [getAuthorTag(fanfiction.author.name)]; - - if (fanfiction.fandom) { - tags.push(getFandomTag(fanfiction.fandom)); - } - - if (fanfiction.relationship) { - tags.push(getRelationshipTag(fanfiction.relationship)); - } - - tags.push(getLanguageTag(fanfiction.language_code)); - tags.push(getOriginTag(fanfiction.source)); - - return tags; -} - -export function getTextToDisplay(text: TextOutput | TextOutput[]) { - if (typeof text === "undefined") { - return; - } - - if (Array.isArray(text)) { - if (text.length === 0) { - return; - } - - return text[0].rich || text[0].raw; - } - - return text.rich || text.raw; -} - -export function getMainTranslation(localized: TextWithTranslationsOutput) { - return localized.translations[0]; -} - -export function isCurrentPage(page: number) { - const currentPage = window.location.pathname.split("/").reverse()[0]; - - return Number(currentPage) === page; -} diff --git a/app/core/core.helpers.ts b/app/core/core.helpers.ts deleted file mode 100644 index fd06233..0000000 --- a/app/core/core.helpers.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function isTrue(value: unknown): value is true { - return value === true; -} - -export function isNotTrue(value: unknown) { - return !isTrue(value); -} diff --git a/app/core/database.ts b/app/core/database.ts deleted file mode 100644 index 0dd97ad..0000000 --- a/app/core/database.ts +++ /dev/null @@ -1,57 +0,0 @@ -// deno-lint-ignore-file no-explicit-any ban-types -import { mongodb } from "@deps"; - -export interface DatabaseOptions extends mongodb.MongoClientOptions { - logger: { - info: (...args: any[]) => any; - error: (...args: any[]) => any; - }; -} - -export class DatabaseClient extends mongodb.MongoClient { - readonly logger: DatabaseOptions["logger"]; - - constructor(readonly uri: string, options: Partial) { - const { logger, ...rest } = options; - - super(uri, rest as DatabaseOptions); - - this.logger = logger || console; - - this.on("open", () => { - this.logger.info("Connected to the database."); - }); - - this.on("connectionClosed", () => { - this.logger.info("Disconnected from the database."); - }); - - this.on("close", () => { - this.logger.info("Disconnected from the database."); - }); - - this.on("error", (err) => { - this.logger.error("There was an unexpected database error", err); - }); - } - - public registerCollection( - name: N, - ) { - const v = this as DatabasePipe; - - Object.assign(v, { [name]: this.db().collection(name) }); - - return v; - } -} - -type DatabasePipe< - D extends DatabaseClient, - N extends string, - T extends Object, -> = - & D - & { - [K in N]: mongodb.Collection; - }; diff --git a/app/core/deps.ts b/app/core/deps.ts deleted file mode 100644 index a838a0b..0000000 --- a/app/core/deps.ts +++ /dev/null @@ -1,24 +0,0 @@ -export * from "@common/deps.ts"; -export * from "@common/utility-types.ts"; - -// @deno-types="express:types" -export { default as express } from "express"; - -export { difference, format as formatDate } from "std/datetime/mod.ts"; -export { - type Cookie, - getCookies, - getSetCookies, - isErrorStatus, - isInformationalStatus, - isRedirectStatus, - isSuccessfulStatus, - Status, -} from "std/http/mod.ts"; -export { default as ansicolors } from "ansi-colors"; -export * as expressHandlebars from "express:handlebars"; -export * as minifier from "minifier"; -export { type WalkEntry, type WalkOptions, walkSync } from "std/fs/mod.ts"; -export { relative } from "std/path/relative.ts"; -export * as mongodb from "mongodb"; -export { encode, Hash } from "checksum"; diff --git a/app/core/endpoints/endpoint.api.ts b/app/core/endpoints/endpoint.api.ts deleted file mode 100644 index 0fab426..0000000 --- a/app/core/endpoints/endpoint.api.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { type express, SafeAny, Status, z } from "@deps"; -import { OPERATION_BY_METHOD, STATUS_TEXT } from "./endpoint.const.ts"; -import { - anySchema, - type InferPayload, - type InferSettings, - type PayloadSchema, - rawPayloadSchema, - sharedSettingsSchema, -} from "./endpoint.utils.ts"; - -/* -------------------------------------------------------------------------- */ -/* Internal Constants and Classes */ -/* -------------------------------------------------------------------------- */ -// #region -const settingsSchema = sharedSettingsSchema.extend({ - resource: z.string(), - data: anySchema, -}); - -class ValidationError extends Error { - constructor( - readonly detail: string, - readonly code: string, - readonly source?: Record, - ) { - super(); - } -} - -export class ApiResponse { - readonly timestamp = new Date(); - - public status!: Status; - public status_text!: typeof STATUS_TEXT[keyof typeof STATUS_TEXT]; - public errors: Error[] | null = null; - public data: T | null = null; - - public constructor(readonly operation: string = "unknown") {} - - public setStatus(status: Status) { - this.status = status; - this.status_text = STATUS_TEXT[this.status]; - - return this; - } - - public setData(data: T) { - this.data = data; - - return this; - } - - public setErrors(...errors: Error[]) { - this.errors = errors.map((e) => { - Object.defineProperty(e, "message", { - enumerable: true, - }); - - return e; - }); - - return this; - } -} - -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Public */ -/* -------------------------------------------------------------------------- */ -// #region -export class EndpointApi< - In extends EndpointApiSettings, - Settings extends InferApiEndpointSettings = InferApiEndpointSettings, -> { - public static init(settings: In) { - return new EndpointApi(settings); - } - - readonly method!: Settings["method"]; - readonly resource!: Settings["resource"]; - readonly path!: Settings["path"]; - readonly data!: Settings["data"]; - - readonly payload: PayloadSchema; - readonly operation: string; - - readonly $handlers: ApiEndpointHandler[] = []; - - get handlers(): ApiEndpointHandler>[] { - return this.$handlers.map((fn) => fn.bind(this)) as ApiEndpointHandler< - In["payload"], - EndpointApi - >[]; - } - - protected constructor({ payload, ...input }: In) { - Object.assign(this, settingsSchema.parse(input)); - - this.operation = `${OPERATION_BY_METHOD[this.method]}.${this.resource}`; - this.payload = rawPayloadSchema.extend(payload || {}) as PayloadSchema< - In["payload"] - >; - } - - protected getResponse(status: Status) { - return new ApiResponse>(this.operation).setStatus( - status, - ); - } - - protected validateRequest: ApiEndpointHandler = - function (req, res, next) { - const validation = this.payload.safeParse(req); - - if (!validation.success) { - const response = this.getResponse(Status.BadRequest).setErrors( - ...validation.error.issues.map(zodIssueToValidationError), - ); - - return res.status(response.status).json(response); - } - - Object.assign(req, validation.data); - - return next(); - }; - - protected wrapHandler(handler: ApiEndpointHandler) { - const handlerName = handler.name.substring(0, 1).toUpperCase() + - handler.name.substring(1); - const name = `wrapped${handlerName}`; - type Params = Parameters; - - const { [name]: wrappedHandler } = { - [name]: async function (req: Params[0], res: Params[1], next: Params[2]) { - try { - const r = await handler.call(this, req, res, next); - - return r; - } catch (error) { - const response = this.getResponse( - Status.InternalServerError, - ).setErrors(error); - - return res.status(response.status).json(response); - } - }, - }; - - return wrappedHandler; - } - - public registerHandler(handler: (typeof this)["$handlers"][number]) { - this.$handlers.push(this.wrapHandler(handler)); - - return this; - } -} - -/** - * @public - */ -export type ExpressApiRequest< - T, - P extends InferPayload = InferPayload, -> = express.Request; - -/** - * @public - */ -export type ExpressApiResponse = express.Response>; - -/** - * @public - */ -export type EndpointApiSettings = z.input; - -/** - * @public - */ -export type ApiEndpointHandler = ( - this: U, - req: ExpressApiRequest, - res: express.Response, - next: express.NextFunction, -) => unknown | Promise; - -/** - * @public - */ -export type AnyApiEndpoint = EndpointApi; - -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Functions */ -/* -------------------------------------------------------------------------- */ -// #region -function zodIssueToValidationError(value: z.ZodIssue) { - const [field, ...rest] = value.path; - - return new ValidationError(value.message, value.code, { - [field]: rest.join("."), - }); -} -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Types */ -/* -------------------------------------------------------------------------- */ -// #region -type InferApiEndpointSettings = InferSettings< - typeof settingsSchema, - S ->; - -// #endregion diff --git a/app/core/endpoints/endpoint.const.ts b/app/core/endpoints/endpoint.const.ts deleted file mode 100644 index 92ca364..0000000 --- a/app/core/endpoints/endpoint.const.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { Status } from "@deps"; - -export const STATUS_TEXT = Object.freeze({ - [Status.Accepted]: "Accepted", - [Status.AlreadyReported]: "Already Reported", - [Status.BadGateway]: "Bad Gateway", - [Status.BadRequest]: "Bad Request", - [Status.Conflict]: "Conflict", - [Status.Continue]: "Continue", - [Status.Created]: "Created", - [Status.EarlyHints]: "Early Hints", - [Status.ExpectationFailed]: "Expectation Failed", - [Status.FailedDependency]: "Failed Dependency", - [Status.Forbidden]: "Forbidden", - [Status.Found]: "Found", - [Status.GatewayTimeout]: "Gateway Timeout", - [Status.Gone]: "Gone", - [Status.HTTPVersionNotSupported]: "HTTP Version Not Supported", - [Status.IMUsed]: "IM Used", - [Status.InsufficientStorage]: "Insufficient Storage", - [Status.InternalServerError]: "Internal Server Error", - [Status.LengthRequired]: "Length Required", - [Status.Locked]: "Locked", - [Status.LoopDetected]: "Loop Detected", - [Status.MethodNotAllowed]: "Method Not Allowed", - [Status.MisdirectedRequest]: "Misdirected Request", - [Status.MovedPermanently]: "Moved Permanently", - [Status.MultiStatus]: "Multi Status", - [Status.MultipleChoices]: "Multiple Choices", - [Status.NetworkAuthenticationRequired]: "Network Authentication Required", - [Status.NoContent]: "No Content", - [Status.NonAuthoritativeInfo]: "Non Authoritative Info", - [Status.NotAcceptable]: "Not Acceptable", - [Status.NotExtended]: "Not Extended", - [Status.NotFound]: "Not Found", - [Status.NotImplemented]: "Not Implemented", - [Status.NotModified]: "Not Modified", - [Status.OK]: "OK", - [Status.PartialContent]: "Partial Content", - [Status.PaymentRequired]: "Payment Required", - [Status.PermanentRedirect]: "Permanent Redirect", - [Status.PreconditionFailed]: "Precondition Failed", - [Status.PreconditionRequired]: "Precondition Required", - [Status.Processing]: "Processing", - [Status.ProxyAuthRequired]: "Proxy Auth Required", - [Status.RequestEntityTooLarge]: "Request Entity Too Large", - [Status.RequestHeaderFieldsTooLarge]: "Request Header Fields Too Large", - [Status.RequestTimeout]: "Request Timeout", - [Status.RequestURITooLong]: "Request URI Too Long", - [Status.RequestedRangeNotSatisfiable]: "Requested Range Not Satisfiable", - [Status.ResetContent]: "Reset Content", - [Status.SeeOther]: "See Other", - [Status.ServiceUnavailable]: "Service Unavailable", - [Status.SwitchingProtocols]: "Switching Protocols", - [Status.Teapot]: "I'm a teapot", - [Status.TemporaryRedirect]: "Temporary Redirect", - [Status.TooEarly]: "Too Early", - [Status.TooManyRequests]: "Too Many Requests", - [Status.Unauthorized]: "Unauthorized", - [Status.UnavailableForLegalReasons]: "Unavailable For Legal Reasons", - [Status.UnprocessableEntity]: "Unprocessable Entity", - [Status.UnsupportedMediaType]: "Unsupported Media Type", - [Status.UpgradeRequired]: "Upgrade Required", - [Status.UseProxy]: "Use Proxy", - [Status.VariantAlsoNegotiates]: "Variant Also Negotiates", -}); - -export const HTTP_METHOD = Object.freeze({ - post: "post", - get: "get", - patch: "patch", - delete: "delete", -}); - -export const OPERATION_BY_METHOD = Object.freeze({ - [HTTP_METHOD.post]: "create", - [HTTP_METHOD.get]: "retrieve", - [HTTP_METHOD.patch]: "update", - [HTTP_METHOD.delete]: "delete", -}); diff --git a/app/core/endpoints/endpoint.module.ts b/app/core/endpoints/endpoint.module.ts deleted file mode 100644 index 31a6028..0000000 --- a/app/core/endpoints/endpoint.module.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./endpoint.view.ts"; -export * from "./endpoint.api.ts"; diff --git a/app/core/endpoints/endpoint.utils.ts b/app/core/endpoints/endpoint.utils.ts deleted file mode 100644 index 3e745fc..0000000 --- a/app/core/endpoints/endpoint.utils.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { z } from "@deps"; -import { HTTP_METHOD } from "./endpoint.const.ts"; - -export const anySchema = z.custom((x) => x instanceof z.Schema); - -export const rawPayloadSchema = z.object({ - body: z.null().default(null).catch(null), - query: z.null().default(null).catch(null), - params: z.null().default(null).catch(null), -}); - -export const sharedSettingsSchema = z.object({ - method: z.nativeEnum(HTTP_METHOD), - path: z.string().default("/"), - payload: z.object({ - body: anySchema.optional(), - query: anySchema.optional(), - params: anySchema.optional(), - }).default({}), -}); - -export type DefaultPayload = typeof rawPayloadSchema["shape"]; - -export type InferSettings< - Schema extends z.ZodTypeAny, - Input extends z.input = z.input, - Output extends z.output = z.output, -> = { - [K in keyof Output]: Input[K] extends Nullable ? Output[K] : Input[K]; -}; - -export type PayloadSchema = z.ZodObject>; - -export type InferPayload = PayloadSchema> = - z.output< - S - >; - -type PayloadSchemaShape = { - body: z.ZodTypeAny; - params: z.ZodTypeAny; - query: z.ZodTypeAny; -}; - -type InferPayloadShape = { - [K in keyof PayloadSchemaShape]: K extends keyof T - ? T[K] extends z.ZodTypeAny ? T[K] : DefaultPayload[K] - : DefaultPayload[K]; -}; - -type Nullable = never | undefined | unknown; diff --git a/app/core/endpoints/endpoint.view.ts b/app/core/endpoints/endpoint.view.ts deleted file mode 100644 index 26ee04d..0000000 --- a/app/core/endpoints/endpoint.view.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { type express, Status, z } from "@deps"; -import { - anySchema, - type InferPayload, - type PayloadSchema, - rawPayloadSchema, - sharedSettingsSchema, -} from "./endpoint.utils.ts"; - -/* -------------------------------------------------------------------------- */ -/* Internal Constants and Classes */ -/* -------------------------------------------------------------------------- */ -// #region -const settingsSchema = sharedSettingsSchema.extend({ - view: z.string(), - context: anySchema, -}); - -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Public */ -/* -------------------------------------------------------------------------- */ -// #region -export class EndpointView { - public static init( - settings: Settings, - ) { - return new EndpointView(settings); - } - - readonly method!: Settings["method"]; - readonly view!: Settings["view"]; - readonly context!: Settings["context"]; - readonly path!: Settings["path"]; - - readonly payload: PayloadSchema; - - private $handlers: ViewEndpointHandler< - Settings["payload"], - EndpointView - >[] = []; - - get handlers() { - return this.$handlers.map((fn) => fn.bind(this)); - } - - protected constructor({ payload, ...input }: Settings) { - Object.assign(this, settingsSchema.parse(input)); - - this.payload = rawPayloadSchema.extend(payload || {}) as PayloadSchema< - Settings["payload"] - >; - - this.$handlers.push(this.validateRequest); - } - - protected validateRequest: ViewEndpointHandler< - Settings, - EndpointView - > = function ( - this: EndpointView, - req, - _res, - next, - ) { - const validation = this.payload.safeParse(req); - - if (!validation.success) { - throw validation.error; - } - - Object.assign(req, validation.data); - - return next(); - }; - - private wrapHandler( - handler: ViewEndpointHandler>, - ) { - const handlerName = handler.name.substring(0, 1).toUpperCase() + - handler.name.substring(1); - const name = `wrapped${handlerName}`; - type Params = Parameters; - - const { [name]: wrappedHandler } = { - [name]: async function ( - this: EndpointView, - req: Params[0], - res: Params[1], - next: Params[2], - ) { - try { - const r = await handler.call(this, req, res, next); - - return r; - } catch (error) { - console.log(error); - res.app.logger.error("There was an error 😭", error); - - return next(error); - } - }, - }; - - return wrappedHandler; - } - - public registerHandler( - handler: ViewEndpointHandler>, - ) { - this.$handlers.push(this.wrapHandler(handler)); - - return this; - } - - public renderOk( - res: express.Response, - context: z.input, - ) { - return res.status(Status.OK).render( - this.view, - this.context.parse(context), - ); - } - - public renderNotFound(req: express.Request, res: express.Response) { - return res.status(Status.NotFound).render("not-found", { path: req.url }); - } - - public renderNotOk( - status: Status, - res: express.Response, - message: string, - ) { - return res.status(status).render("error", { status, message }); - } -} - -export type ExpressWebRequest = InferPayload> = - express.Request; - -export type ViewEndpointHandler = ( - this: U, - req: ExpressWebRequest, - res: express.Response, - next: express.NextFunction, -) => unknown | Promise; - -export type EndpointViewSettings = z.input; -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Functions */ -/* -------------------------------------------------------------------------- */ -// #region -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Types */ -/* -------------------------------------------------------------------------- */ -// #region - -// #endregion diff --git a/app/core/fanfiction.ts b/app/core/fanfiction.ts deleted file mode 100644 index e5ecee7..0000000 --- a/app/core/fanfiction.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { encode, Hash, mongodb, z } from "@deps"; -import { LanguageCodeSchema, LanguageNameSchema } from "./localization.ts"; - -export type AuthorInput = z.input; -export type AuthorOutput = z.output; - -export const AuthorSchema = z.object({ - name: z.string().min(1).default("Anonymous"), - url: z.string().url(), -}); - -export type TextInput = z.input; -export type TextOutput = z.output; - -export const TextSchema = z.object({ - raw: z.string().min(1).trim(), - rich: z.string().trim().default(""), - language_code: LanguageCodeSchema, - language: LanguageNameSchema, -}); - -export type TextWithTranslationsInput = z.input< - typeof TextWithTranslationsSchema ->; -export type TextWithTranslationsOutput = z.output< - typeof TextWithTranslationsSchema ->; - -export const TextWithTranslationsSchema = z.object({ - original: TextSchema, - translations: z.array(TextSchema).default([]), -}); - -export type ParagraphInput = z.input; -export type ParagraphOutput = z.output; - -export const ParagraphSchema = TextWithTranslationsSchema.extend({ - index: z.number().int(), - hash: z.string().default(""), -}).transform(createHash); - -export type OneShotInput = z.input; -export type OneShotOutput = z.output; - -export const OneShotSchema = z.object({ - paragraphs: z.array(ParagraphSchema).default([]), -}); - -export type MultiChapterInput = z.input; -export type MultiChapterOutput = z.output; - -export const MultiChapterSchema = z.object({ - title: TextWithTranslationsSchema, - paragraphs: z.array(ParagraphSchema).default([]), - summary: TextWithTranslationsSchema.nullable().default(null), -}); - -export type ChapterInput = z.input; -export type ChapterOutput = z.output; - -export const ChapterSchema = z.union([OneShotSchema, MultiChapterSchema]); - -export type LiteFanfictionInput = z.input; -export type LiteFanfictionOutput = z.output; - -export const LiteFanfictionSchema = z.object({ - id: z.string().uuid().default(createUuid), - created_at: z.coerce.date().default(createTimestamp), - updated_at: z.coerce.date().default(createTimestamp), - author: AuthorSchema, - origin_id: z.coerce.string().min(1), - origin_url: z.string().url(), - source: z.string().default(""), - language: LanguageNameSchema, - language_code: LanguageCodeSchema, - title: TextWithTranslationsSchema, - summary: TextWithTranslationsSchema, - fandom: z.string(), - relationship_characters: z.array(z.string().min(1)).default([]), - relationship: z.string().default(""), - is_romantic: z.boolean().default(true), - is_one_shot: z.boolean(), -}); - -export const RawFanfictionSchema = LiteFanfictionSchema.extend({ - kind: z.literal("fanfiction").default("fanfiction").catch("fanfiction"), - chapters: z.array(ChapterSchema).default([]), -}); - -export type FanfictionInput = z.input; -export type FanfictionOutput = z.output; - -export const FanfictionSchema = RawFanfictionSchema.transform(addSource) - .transform(setRelationship); - -export type FanfictionDocumentInput = z.input; -export type FanfictionDocumentOutput = z.output< - typeof FanfictionDocumentSchema ->; - -export const FanfictionDocumentSchema = FanfictionSchema.transform( - toFanfictionDocument, -); - -function createHash( - value: T, -) { - if (!value.hash) { - value.hash = new Hash("md5").digest(encode(value.original.raw)).hex(); - } - - return value; -} - -function createUuid() { - return globalThis.crypto.randomUUID(); -} - -function addSource( - value: T, -) { - if (!value.source) { - value.source = new URL(value.origin_url).hostname; - } - - return value; -} - -function setRelationship< - T extends { relationship_characters: string[]; relationship?: string }, ->(value: T) { - if (value.relationship_characters.length > 0) { - value.relationship = value.relationship_characters.join("/"); - } - - return value; -} - -function createTimestamp() { - return new Date(); -} - -function toFanfictionDocument(fanfiction: T) { - return { ...fanfiction, _id: new mongodb.BSON.UUID(fanfiction.id) }; -} diff --git a/app/core/localization.ts b/app/core/localization.ts deleted file mode 100644 index 287f4b5..0000000 --- a/app/core/localization.ts +++ /dev/null @@ -1,482 +0,0 @@ -import { z } from "@deps"; - -/** - * An object mapping language names to their `ISO 639-1` language codes. - * - * This is a read-only object that contains mappings for many common languages. - * The keys are the language names and the values are the corresponding - * `ISO 639-1` language code. - * - * This can be used to look up the language code for a given language name. - * - * @example - * - * ```typescript - * console.log(LANGUAGE_CODE["English"]); // "en" - * console.log(LANGUAGE_CODE["Japanese"]); // "ja" - * ``` - */ -export const LANGUAGE_CODE = Object.freeze({ - "Afar": "aa", - "Abkhazian": "ab", - "Avestan": "ae", - "Afrikaans": "af", - "Akan": "ak", - "Amharic": "am", - "Aragonese": "an", - "Arabic": "ar", - "Assamese": "as", - "Avaric": "av", - "Aymara": "ay", - "Azerbaijani": "az", - "Bashkir": "ba", - "Belarusian": "be", - "Bulgarian": "bg", - "Bislama": "bi", - "Bambara": "bm", - "Bengali": "bn", - "Tibetan": "bo", - "Breton": "br", - "Bosnian": "bs", - "Catalan": "ca", - "Chechen": "ce", - "Chamorro": "ch", - "Corsican": "co", - "Cree": "cr", - "Czech": "cs", - "Church Slavonic": "cu", - "Chuvash": "cv", - "Welsh": "cy", - "Danish": "da", - "German": "de", - "Divehi": "dv", - "Dzongkha": "dz", - "Ewe": "ee", - "Greek": "el", - "English": "en", - "Esperanto": "eo", - "Spanish": "es", - "Estonian": "et", - "Basque": "eu", - "Persian": "fa", - "Fulah": "ff", - "Finnish": "fi", - "Fijian": "fj", - "Faroese": "fo", - "French": "fr", - "Western Frisian": "fy", - "Irish": "ga", - "Gaelic": "gd", - "Galician": "gl", - "Guaraní": "gn", - "Gujarati": "gu", - "Manx": "gv", - "Hausa": "ha", - "Hebrew": "he", - "Hindi": "hi", - "Hiri Motu": "ho", - "Croatian": "hr", - "Haitian": "ht", - "Hungarian": "hu", - "Armenian": "hy", - "Herero": "hz", - "Interlingua": "ia", - "Indonesian": "id", - "Interlingue": "ie", - "Igbo": "ig", - "Sichuan Yi": "ii", - "Inupiaq": "ik", - "Ido": "io", - "Icelandic": "is", - "Italian": "it", - "Inuktitut": "iu", - "Japanese": "ja", - "Javanese": "jv", - "Georgian": "ka", - "Kongo": "kg", - "Kikuyu": "ki", - "Kuanyama": "kj", - "Kazakh": "kk", - "Kalaallisut": "kl", - "Central Khmer": "km", - "Kannada": "kn", - "Korean": "ko", - "Kanuri": "kr", - "Kashmiri": "ks", - "Kurdish": "ku", - "Komi": "kv", - "Cornish": "kw", - "Kirghiz": "ky", - "Latin": "la", - "Luxembourgish": "lb", - "Ganda": "lg", - "Limburgan": "li", - "Lingala": "ln", - "Lao": "lo", - "Lithuanian": "lt", - "Luba-Katanga": "lu", - "Latvian": "lv", - "Malagasy": "mg", - "Marshallese": "mh", - "Maori": "mi", - "Macedonian": "mk", - "Malayalam": "ml", - "Mongolian": "mn", - "Marathi": "mr", - "Malay": "ms", - "Maltese": "mt", - "Burmese": "my", - "Nauru": "na", - "Norwegian Bokmål": "nb", - "North Ndebele": "nd", - "Nepali": "ne", - "Ndonga": "ng", - "Dutch": "nl", - "Norwegian Nynorsk": "nn", - "Norwegian": "no", - "South Ndebele": "nr", - "Navajo": "nv", - "Chichewa": "ny", - "Occitan": "oc", - "Ojibwa": "oj", - "Oromo": "om", - "Oriya": "or", - "Ossetian,": "os", - "Panjabi": "pa", - "Pali": "pi", - "Polish": "pl", - "Pashto": "ps", - "Portuguese": "pt", - "Quechua": "qu", - "Romansh": "rm", - "Rundi": "rn", - "Romanian": "ro", - "Russian": "ru", - "Kinyarwanda": "rw", - "Sanskrit": "sa", - "Sardinian": "sc", - "Sindhi": "sd", - "Northern Sami": "se", - "Sango": "sg", - "Sinhala": "si", - "Slovak": "sk", - "Slovene": "sl", - "Samoan": "sm", - "Shona": "sn", - "Somali": "so", - "Albanian": "sq", - "Serbian": "sr", - "Swati": "ss", - "Southern Sotho": "st", - "Sundanese": "su", - "Swedish": "sv", - "Swahili": "sw", - "Tamil": "ta", - "Telugu": "te", - "Tajik": "tg", - "Thai": "th", - "Tigrinya": "ti", - "Turkmen": "tk", - "Tagalog": "tl", - "Tswana": "tn", - "Tongan": "to", - "Turkish": "tr", - "Tsonga": "ts", - "Tatar": "tt", - "Twi": "tw", - "Tahitian": "ty", - "Uighur": "ug", - "Ukrainian": "uk", - "Urdu": "ur", - "Uzbek": "uz", - "Venda": "ve", - "Vietnamese": "vi", - "Volapük": "vo", - "Walloon": "wa", - "Wolof": "wo", - "Xhosa": "xh", - "Yiddish": "yi", - "Yoruba": "yo", - "Zhuang": "za", - "Chinese": "zh", - "Zulu": "zu", -}); - -/** - * A Zod schema that validates language codes. - * - * This uses Zod's `nativeEnum()` method to create a schema that validates - * against the keys of the {@link LANGUAGE_CODE} object. - * - * This can be used to validate that a string is a valid `ISO 639` language code. - * - * @example - * - * ```typescript - * const validated = LanguageCodeSchema.parse("en"); // ok - * const invalid = LanguageCodeSchema.parse("xyz"); // throws error - * ``` - */ -export const LanguageCodeSchema = z.nativeEnum(LANGUAGE_CODE); - -/** - * Represents a valid `ISO 639-1` language code. - */ -export type LanguageCode = z.output; - -/** - * Gets the language code for the given language name. - * - * @param name - The name of the language to get the code for. - * @returns The `ISO 639-1` language code for the given language name. - * @throws {Error} If the provided language name is not supported. - */ -export function getLanguageCode(name: string): LanguageCode { - if (!(name in LANGUAGE_CODE)) { - throw new Error(`${name} is not a supported language name.`); - } - - return LANGUAGE_CODE[name as keyof typeof LANGUAGE_CODE]; -} - -/** - * An object mapping `ISO 639-1` language codes to their language names. - * - * This is a read-only object that contains mappings from language codes - * to human-readable language names. - * - * The keys are the `ISO 639-1` two-letter language codes and the values - * are the corresponding language names in English. - * - * This can be used to look up the name for a language given its code. - * - * @example - * - * ```typescript - * console.log(LANGUAGE_NAME["en"]); // "English" - * console.log(LANGUAGE_NAME["ja"]); // "Japanese" - * ``` - */ -export const LANGUAGE_NAME = Object.freeze({ - aa: "Afar", - ab: "Abkhazian", - ae: "Avestan", - af: "Afrikaans", - ak: "Akan", - am: "Amharic", - an: "Aragonese", - ar: "Arabic", - as: "Assamese", - av: "Avaric", - ay: "Aymara", - az: "Azerbaijani", - ba: "Bashkir", - be: "Belarusian", - bg: "Bulgarian", - bi: "Bislama", - bm: "Bambara", - bn: "Bengali", - bo: "Tibetan", - br: "Breton", - bs: "Bosnian", - ca: "Catalan", - ce: "Chechen", - ch: "Chamorro", - co: "Corsican", - cr: "Cree", - cs: "Czech", - cu: "Church Slavonic", - cv: "Chuvash", - cy: "Welsh", - da: "Danish", - de: "German", - dv: "Divehi", - dz: "Dzongkha", - ee: "Ewe", - el: "Greek", - en: "English", - eo: "Esperanto", - es: "Spanish", - et: "Estonian", - eu: "Basque", - fa: "Persian", - ff: "Fulah", - fi: "Finnish", - fj: "Fijian", - fo: "Faroese", - fr: "French", - fy: "Western Frisian", - ga: "Irish", - gd: "Gaelic", - gl: "Galician", - gn: "Guaraní", - gu: "Gujarati", - gv: "Manx", - ha: "Hausa", - he: "Hebrew", - hi: "Hindi", - ho: "Hiri Motu", - hr: "Croatian", - ht: "Haitian", - hu: "Hungarian", - hy: "Armenian", - hz: "Herero", - ia: "Interlingua", - id: "Indonesian", - ie: "Interlingue", - ig: "Igbo", - ii: "Sichuan Yi", - ik: "Inupiaq", - io: "Ido", - is: "Icelandic", - it: "Italian", - iu: "Inuktitut", - ja: "Japanese", - jv: "Javanese", - ka: "Georgian", - kg: "Kongo", - ki: "Kikuyu", - kj: "Kuanyama", - kk: "Kazakh", - kl: "Kalaallisut", - km: "Central Khmer", - kn: "Kannada", - ko: "Korean", - kr: "Kanuri", - ks: "Kashmiri", - ku: "Kurdish", - kv: "Komi", - kw: "Cornish", - ky: "Kirghiz", - la: "Latin", - lb: "Luxembourgish", - lg: "Ganda", - li: "Limburgan", - ln: "Lingala", - lo: "Lao", - lt: "Lithuanian", - lu: "Luba-Katanga", - lv: "Latvian", - mg: "Malagasy", - mh: "Marshallese", - mi: "Maori", - mk: "Macedonian", - ml: "Malayalam", - mn: "Mongolian", - mr: "Marathi", - ms: "Malay", - mt: "Maltese", - my: "Burmese", - na: "Nauru", - nb: "Norwegian Bokmål", - nd: "North Ndebele", - ne: "Nepali", - ng: "Ndonga", - nl: "Dutch", - nn: "Norwegian Nynorsk", - no: "Norwegian", - nr: "South Ndebele", - nv: "Navajo", - ny: "Chichewa", - oc: "Occitan", - oj: "Ojibwa", - om: "Oromo", - or: "Oriya", - os: "Ossetian,", - pa: "Panjabi", - pi: "Pali", - pl: "Polish", - ps: "Pashto", - pt: "Portuguese", - qu: "Quechua", - rm: "Romansh", - rn: "Rundi", - ro: "Romanian", - ru: "Russian", - rw: "Kinyarwanda", - sa: "Sanskrit", - sc: "Sardinian", - sd: "Sindhi", - se: "Northern Sami", - sg: "Sango", - si: "Sinhala", - sk: "Slovak", - sl: "Slovene", - sm: "Samoan", - sn: "Shona", - so: "Somali", - sq: "Albanian", - sr: "Serbian", - ss: "Swati", - st: "Southern Sotho", - su: "Sundanese", - sv: "Swedish", - sw: "Swahili", - ta: "Tamil", - te: "Telugu", - tg: "Tajik", - th: "Thai", - ti: "Tigrinya", - tk: "Turkmen", - tl: "Tagalog", - tn: "Tswana", - to: "Tongan", - tr: "Turkish", - ts: "Tsonga", - tt: "Tatar", - tw: "Twi", - ty: "Tahitian", - ug: "Uighur", - uk: "Ukrainian", - ur: "Urdu", - uz: "Uzbek", - ve: "Venda", - vi: "Vietnamese", - vo: "Volapük", - wa: "Walloon", - wo: "Wolof", - xh: "Xhosa", - yi: "Yiddish", - yo: "Yoruba", - za: "Zhuang", - zh: "Chinese", - zu: "Zulu", -}); - -/** - * A Zod schema that validates language name strings. - * - * This uses Zod's `nativeEnum()` method to create a schema that validates - * strings against the keys of the {@link LANGUAGE_NAME} object. - * - * This can be used to validate that a string is a valid `ISO 639` - * language name in English. - * - * @example - * - * ```typescript - * const validated = LanguageNameSchema.parse("English"); // ok - * const invalid = LanguageNameSchema.parse("Foo"); // throws error - * ``` - */ -export const LanguageNameSchema = z.nativeEnum(LANGUAGE_NAME); - -/** - * Represents a valid `ISO 639` language name in English. - */ -export type LanguageName = z.output; - -/** - * Gets the language name for the given language code. - * - * @param code - The `ISO 639-1` language code to get the name for. - * @returns The English name of the language corresponding to the code. - * @throws {Error} If the provided code is not a supported `ISO 639-1` code. - */ -export function getLanguageName(code: string): LanguageName { - if (!(code in LANGUAGE_NAME)) { - throw new Error(`${code} is not a supported language code.`); - } - - return LANGUAGE_NAME[code as keyof typeof LANGUAGE_NAME]; -} diff --git a/app/core/logger.ts b/app/core/logger.ts deleted file mode 100644 index e890bbf..0000000 --- a/app/core/logger.ts +++ /dev/null @@ -1,557 +0,0 @@ -// deno-lint-ignore-file no-explicit-any -import { - ansicolors, - formatDate, - isErrorStatus, - isInformationalStatus, - isRedirectStatus, - isSuccessfulStatus, - z, -} from "@deps"; - -/* -------------------------------------------------------------------------- */ -/* Internal Constants and Classes */ -/* -------------------------------------------------------------------------- */ -// #region -const SeverityName = { - Silent: "Silent", - Debug: "Debug", - Informational: "Informational", - Warning: "Warning", - Error: "Error", -} as const; - -enum LoggerSeverity { - Silent = 0, - Debug = 1, - Informational = 2, - Warning = 3, - Error = 4, -} - -const Level = { - Error: "ERROR", - Warn: "WARN", - Info: "INFO", - Http: "HTTP", - Debug: "DEBUG", -} as const; - -const Theme = { - [Level.Error]: ansicolors.bold.red, - [Level.Warn]: ansicolors.bold.yellow, - [Level.Info]: ansicolors.bold.green, - [Level.Http]: ansicolors.bold.cyan, - [Level.Debug]: ansicolors.bold.blue, -}; - -const HttpStatusTheme = { - Informational: ansicolors.bold.cyan, - Successful: ansicolors.bold.cyan, - Redirection: ansicolors.bold.yellow, - Error: ansicolors.bold.red, - Default: ansicolors.bold, -}; - -const sharedTemplate = `${ - ansicolors.bold.dim( - "{@timestamp}", - ) -} {log.level} [${ansicolors.bold.white("{log.logger}")}]`; - -const prettyTemplate = `${sharedTemplate} ${ - ansicolors.dim( - "{log.origin.file.path}:{log.origin.file.line}:{log.origin.file.column}", - ) -} ${ansicolors.yellow("{message}")} {data}`; - -const prettyHttpTemplate = `${sharedTemplate} "${ - ansicolors.bold.greenBright( - "{http.request.method} {http.request.url.original}", - ) -} ${ - ansicolors.bold.green.dim( - "HTTP/{http.version}", - ) -}" {http.response.status_code} ${ansicolors.bold.dim("{event.duration}ms")}`; - -const prettyErrorTemplate = `${sharedTemplate} ${ - ansicolors.dim( - "{log.origin.file.path}:{log.origin.file.line}:{log.origin.file.column}", - ) -} ${ansicolors.yellow("{message}")}\n${ansicolors.bold.bgRed("{error.id}")}: ${ - ansicolors.bold("{error.message}") -} {error.stack_trace}`; - -const DefaultTransports = { - [SeverityName.Silent]: doNothing, - [SeverityName.Debug]: console.debug, - [SeverityName.Informational]: console.info, - [SeverityName.Warning]: console.warn, - [SeverityName.Error]: console.error, -}; - -const LoggerSettingsSchema = z.object({ - severity: z.nativeEnum(LoggerSeverity), - application: z.string(), - environment: z.string(), - module: z.string().optional(), - id: z.string().optional(), - version: z.string(), - padding: z.number().int().default(Level.Debug.length + 1), - mode: z.enum(["pretty", "json"]), - transports: z.custom().default(DefaultTransports), - prettyTemplate: z.string().default(prettyTemplate), - prettyErrorTemplate: z.string().default(prettyErrorTemplate), - prettyHttpTemplate: z.string().default(prettyHttpTemplate), - inspectOptions: z.custom().default({ - colors: true, - }), -}); - -class LogObject { - #error?: LogObject.Error; - #request?: LogObject.Request; - #response?: LogObject.Response; - - readonly "@timestamp" = formatDate(new Date(), "yyyy-MM-dd HH:mm:ss.SSS"); - public "log.level": Logger.LevelName = "" as Logger.LevelName; - public message = ""; - public data: LogObject.OptionalField> = undefined; - public labels: LogObject.OptionalField> = undefined; - public tags: LogObject.OptionalField = undefined; - public "log.logger" = "unknown"; - public "log.origin.file.column" = 0; - public "log.origin.file.line" = 0; - public "log.origin.file.name" = ""; - public "log.origin.file.path" = ""; - public "error.code": LogObject.OptionalField = undefined; - public "error.id": LogObject.OptionalField = undefined; - public "error.message": LogObject.OptionalField = undefined; - public "error.stack_trace": LogObject.OptionalField = undefined; - public "service.name": LogObject.OptionalField = undefined; - public "service.version": LogObject.OptionalField = undefined; - public "service.environment": LogObject.OptionalField = undefined; - public "service.id": LogObject.OptionalField = undefined; - readonly "process.args" = Deno.args; - public "event.duration": LogObject.OptionalField = undefined; - public "http.version": LogObject.OptionalField = undefined; - public "http.request.id": LogObject.OptionalField = undefined; - public "http.request.method": LogObject.OptionalField = undefined; - public "http.request.mime_type": LogObject.OptionalField = undefined; - public "http.request.referrer": LogObject.OptionalField = undefined; - public "http.request.url.original": LogObject.OptionalField = - undefined; - public "http.response.mime_type": LogObject.OptionalField = undefined; - public "http.response.status_code": LogObject.OptionalField = - undefined; - - constructor( - error?: LogObject.Error, - request?: LogObject.Request, - response?: LogObject.Response, - ) { - this.#error = error; - this.#request = request; - this.#response = response; - - if (this.#error) { - this.setErrorFields(this.#error); - } - - if (this.#request) { - this.setRequestFields(this.#request); - } - - if (this.#response) { - this.setResponseFields(this.#response); - } - } - - public setBaseFields( - message: string, - data?: Array, - labels?: Record, - tags?: Array, - ) { - this.message = message; - this.data = data; - this.labels = labels; - this.tags = tags; - - return this; - } - - public setErrorFields(error: LogObject.Error) { - if (error instanceof Error) { - this["error.id"] = error.name; - this["error.message"] = error.message; - this["error.stack_trace"] = getStackTrace(error).join(", "); - } else { - this["error.code"] = error.code; - this["error.id"] = error.id; - this["error.message"] = error.title; - this["error.message"] = getStackTrace().join("\n"); - this["error.stack_trace"] = getStackTrace().join(", "); - } - - return this; - } - - public setLogFields(level: string, logger: string) { - const origin = getLastFileStack( - this.#error instanceof Error ? this.#error : undefined, - ); - - this["log.level"] = level as Logger.LevelName; - this["log.logger"] = logger; - this["log.origin.file.column"] = origin.column; - this["log.origin.file.line"] = origin.line; - this["log.origin.file.name"] = origin.filename; - this["log.origin.file.path"] = origin.path; - - return this; - } - - public setRequestFields(request: LogObject.Request) { - this["http.version"] = request.httpVersion; - this["http.request.id"] = request.id; - this["http.request.method"] = request.method; - this["http.request.mime_type"] = request.get("content-type"); - this["http.request.referrer"] = request.get("referrer"); - this["http.request.url.original"] = request.originalUrl; - - return this; - } - - public setResponseFields(response: LogObject.Response) { - this["event.duration"] = response.duration || 0.1; - this["http.response.mime_type"] = response.get("content-type"); - this["http.response.status_code"] = response.statusCode; - - return this; - } - - public setServiceFields( - environment: string, - name?: string, - version?: string, - id?: string, - ) { - this["service.environment"] = environment; - this["service.name"] = name; - this["service.version"] = version; - this["service.id"] = id; - - return this; - } -} - -type LogOptions = { - message: string; - args?: unknown[]; - error?: LogObject.Error; - request?: LogObject.Request; - response?: LogObject.Response; -}; - -abstract class BaseLogger { - public settings: Logger.DefaultSettings; - - constructor(settings: Logger.Settings) { - this.settings = LoggerSettingsSchema.parse(settings); - } - - protected abstract format(logObject: LogObject): string; - - protected isSilentMode(severity: LoggerSeverity) { - return this.settings.severity === 0 || severity < this.settings.severity; - } - - protected log( - severity: LoggerSeverity, - level: Logger.LevelName, - options: LogOptions, - ) { - let loggerName = this.settings.application; - - if (this.settings.module) { - loggerName += `:${this.settings.module}`; - } - - const logObject = new LogObject( - options.error, - options.request, - options.response, - ) - .setBaseFields(options.message, options.args) - .setLogFields(level, loggerName) - .setServiceFields( - this.settings.environment, - this.settings.application, - this.settings.version, - this.settings.id, - ); - - const formatted = this.format(logObject); - - if (this.isSilentMode(severity)) { - this.settings.transports[SeverityName.Silent](formatted); - } else { - const severityName = LoggerSeverity[severity] as Logger.SeverityName; - const transport = this.settings.transports[severityName]; - - transport(formatted); - } - - return logObject; - } - - public debug(message: string, ...args: unknown[]) { - return this.log(LoggerSeverity.Debug, Level.Debug, { message, args }); - } - - public info(message: string, ...args: unknown[]) { - return this.log(LoggerSeverity.Informational, Level.Info, { - message, - args, - }); - } - - public http(request: LogObject.Request, response: LogObject.Response) { - return this.log(LoggerSeverity.Informational, Level.Http, { - request, - response, - message: "", - }); - } - - public warn(message: string, ...args: unknown[]) { - return this.log(LoggerSeverity.Warning, Level.Warn, { message, args }); - } - - public error(message: string, ...args: unknown[]) { - const [error] = args; - - return this.log(LoggerSeverity.Error, Level.Error, { - message, - args, - error: error as LogObject.Error, - }); - } -} - -class PrettyLogger extends BaseLogger { - protected inspect(data: unknown) { - return Deno.inspect(data, this.settings.inspectOptions); - } - - protected prettifyStack(value: string) { - return ansicolors.yellow(` • ${ansicolors.underline(value)}`); - } - - protected substitute( - value: string, - substitutions: Record, - ): string { - const regex = /{(.+?)\}/g; - - return value.replace(regex, (match: string, _index: number) => { - const key = match.replace("{", "").replace("}", ""); - const substitution = substitutions[key]; - - return substitution ? substitution : match; - }); - } - - protected applyTemplate(template: string, log: LogObject) { - const args = log.data - ? log.data.map(this.inspect.bind(this)).join("\n") - : ""; - const stack = log["error.stack_trace"] - ? log["error.stack_trace"].split(", ").map(this.prettifyStack).join("\n") - : ""; - - if (args) { - template = template.replaceAll("{data}", `\n${args}`); - } - - if (stack) { - template = template.replaceAll("{error.stack_trace}", `\n${stack}`); - } - - return this.substitute(template, log); - } - - protected getStatusColor(status: number) { - if (isErrorStatus(status)) { - return HttpStatusTheme.Error; - } - - if (isSuccessfulStatus(status)) { - return HttpStatusTheme.Successful; - } - - if (isInformationalStatus(status)) { - return HttpStatusTheme.Informational; - } - - if (isRedirectStatus(status)) { - return HttpStatusTheme.Redirection; - } - - return HttpStatusTheme.Default; - } - - protected getPrettyTemplate(level: Logger.LevelName, status?: number) { - const spaces = " ".repeat(this.settings.padding - level.length); - let template = level === "HTTP" - ? this.settings.prettyHttpTemplate - : level === "ERROR" - ? this.settings.prettyErrorTemplate - : this.settings.prettyTemplate; - - if (status) { - template = template.replaceAll( - "{http.response.status_code}", - this.getStatusColor(status)("{http.response.status_code}"), - ); - } - - return template.replaceAll( - "{log.level}", - Theme[level](`{log.level}${spaces}`), - ); - } - - protected format(logObject: LogObject) { - return this.applyTemplate( - this.getPrettyTemplate( - logObject["log.level"], - logObject["http.response.status_code"], - ), - logObject, - ); - } -} - -class JsonLogger extends BaseLogger { - protected format(logObject: LogObject) { - return JSON.stringify(logObject, null, 2); - } -} - -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Public */ -/* -------------------------------------------------------------------------- */ -// #region -export namespace Logger { - export type LevelName = (typeof Level)[keyof typeof Level]; - - export type SeverityName = (typeof SeverityName)[keyof typeof SeverityName]; - - export type Settings = z.input; - - export type DefaultSettings = z.output; - - export const Severity = LoggerSeverity; -} - -export type Logger = BaseLogger; - -export function createLogger(settings: Logger.Settings) { - return settings.mode === "pretty" - ? new PrettyLogger(settings) - : new JsonLogger(settings); -} - -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Functions */ -/* -------------------------------------------------------------------------- */ -// #region -function getLastFileStack(error = new Error()) { - const stack = error.stack as string; - const files = stack.split("\n").filter(isFileLine); - - return getStackDetails(formatStackLine(files[files.length - 1].trim())); -} - -function getStackTrace(error = new Error()) { - const stack = error.stack as string; - - return formatStackTrace(stack); -} - -function formatStackTrace(stack: string) { - return stack.split("\n").filter(isFileLine).map(trim).map(formatStackLine); -} - -function isFileLine(line: string) { - return line.includes("file://") && !line.includes("node_modules/"); -} - -function trim(value: string) { - return value.trim(); -} - -function formatStackLine(line: string) { - const cwd = `${Deno.cwd()}/`; - const endIndex = line.indexOf(cwd); - - return line.substring(endIndex).replace(cwd, "").replaceAll(")", ""); -} - -function getStackDetails(stack: string) { - const parts = stack.split(":"); - const fileParts = parts[0].split("/"); - const filename = fileParts[fileParts.length - 1]; - - return { - column: Number(parts[2]), - line: Number(parts[1]), - filename, - path: parts[0], - }; -} - -function doNothing(..._: unknown[]) { - return; -} - -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Types */ -/* -------------------------------------------------------------------------- */ -// #region -namespace LogObject { - export type Error = { - code?: string; - id?: string; - message?: string; - name?: string; - stack?: string; - title?: string; - }; - - export type Request = { - httpVersion?: string; - id?: string; - method?: string; - get: (value: string) => string | undefined; - originalUrl?: string; - }; - - export type Response = { - duration?: number; - get: (value: string) => string | undefined; - statusCode?: number; - }; - - export type OptionalField = T | undefined; -} -// #endregion diff --git a/app/core/mod.ts b/app/core/mod.ts deleted file mode 100644 index b826bd7..0000000 --- a/app/core/mod.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./database.ts"; -export * from "./fanfiction.ts"; -export * from "./localization.ts"; -export * from "./logger.ts"; -export * from "./endpoints/endpoint.module.ts"; -export * from "./tag.ts"; -export * from "./utils.ts"; diff --git a/app/core/tag.ts b/app/core/tag.ts deleted file mode 100644 index 352aada..0000000 --- a/app/core/tag.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { z } from "@deps"; -import { getLanguageName, LanguageCode } from "./localization.ts"; - -/* -------------------------------------------------------------------------- */ -/* Internal Constants and Classes */ -/* -------------------------------------------------------------------------- */ -// #region - -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Public */ -/* -------------------------------------------------------------------------- */ -// #region -export enum TagName { - Relationship = "relationship", - Fandom = "fandom", - Language = "language", - Origin = "origin", - Author = "author", -} - -export const QUERY_BY_TAG_NAME = Object.freeze({ - [TagName.Relationship]: "relationship=", - [TagName.Fandom]: "fandom=", - [TagName.Language]: "language_code=", - [TagName.Origin]: "origin=", - [TagName.Author]: "author=", -}); - -export const ICON_BY_TAG_NAME = Object.freeze({ - [TagName.Relationship]: "user-group", - [TagName.Fandom]: "scroll", - [TagName.Language]: "globe", - [TagName.Origin]: "link", - [TagName.Author]: "user", -}); - -export type Tag = z.output; - -export const TagSchema = z.object({ - href: z.string().default(""), - icon: z.nativeEnum(ICON_BY_TAG_NAME), - name: z.nativeEnum(TagName), - value: z.string(), - total: z.number().optional(), -}).transform((tag) => { - tag.href = getTagHref(tag.name, tag.value); - - return tag; -}); - -export function getTagHref(name: TagName, ...values: string[]) { - return `/fanfiction-catalog?${QUERY_BY_TAG_NAME[name]}${ - values.join(",").replaceAll(" ", "%20") - }`; -} - -export function getLanguageTag( - code: LanguageCode, - total?: number, -): Tag { - return { - name: TagName.Language, - value: getLanguageName(code), - icon: ICON_BY_TAG_NAME[TagName.Language], - href: getTagHref(TagName.Language, code), - total, - }; -} - -export function getOriginTag(origin: string, total?: number): Tag { - return { - name: TagName.Origin, - value: origin, - icon: ICON_BY_TAG_NAME[TagName.Origin], - href: getTagHref(TagName.Origin, origin), - total, - }; -} - -export function getFandomTag(fandom: string, total?: number): Tag { - return { - name: TagName.Fandom, - value: fandom, - icon: ICON_BY_TAG_NAME[TagName.Fandom], - href: getTagHref(TagName.Fandom, fandom), - total, - }; -} - -export function getRelationshipTag( - relationship: string, - total?: number, -): Tag { - return { - name: TagName.Relationship, - value: relationship, - icon: ICON_BY_TAG_NAME[TagName.Relationship], - href: getTagHref(TagName.Relationship, relationship.replaceAll("/", ",")), - total, - }; -} - -export function getAuthorTag( - authorName: string, - total?: number, -): Tag { - return { - name: TagName.Author, - value: authorName, - icon: ICON_BY_TAG_NAME[TagName.Author], - href: getTagHref(TagName.Author, authorName), - total, - }; -} - -export function sortTagsDescending(tags: Tag[]) { - return (tags as Required[]).sort(isMoreFrequent); -} - -export function sortTagsAlphabetically(tags: Tag[]) { - return (tags as Required[]).sort(comesBeforeAlphabetically); -} -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Functions */ -/* -------------------------------------------------------------------------- */ -// #region -function isMoreFrequent(tag1: Required, tag2: Required) { - return tag2.total - tag1.total; -} - -function comesBeforeAlphabetically(tag1: Required, tag2: Required) { - return tag1.value.localeCompare(tag2.value); -} -// #endregion - -/* -------------------------------------------------------------------------- */ -/* Internal Types */ -/* -------------------------------------------------------------------------- */ -// #region -// #endregion diff --git a/app/core/utils.ts b/app/core/utils.ts deleted file mode 100644 index c155381..0000000 --- a/app/core/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { express } from "@deps"; - -export function isFunction(value: unknown): value is CallableFunction { - return typeof value === "function"; -} - -export function isMiddleware( - value: unknown, -): value is Middleware { - return typeof value === "function" && value.length >= 3 && - typeof (value as Middleware).priority === "number"; -} - -export type Middleware = express.RequestHandler & { - priority: number; -}; diff --git a/app/login/login.middlewares.ts b/app/login/login.middlewares.ts deleted file mode 100644 index fe8699e..0000000 --- a/app/login/login.middlewares.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { type express, getCookies } from "@deps"; -import { Middleware } from "@common"; -import { - removeSessionCookie, - retrieveSessionCookie, - type SessionCookie, -} from "./login.services.ts"; - -export const setCookie = function setCookie( - req: express.Request, - res: express.Response, - next: express.NextFunction, -) { - // @ts-ignore: ¯\_(ツ)_/¯ - const headers = new Headers(Object.entries(req.headers)); - const token = getCookies(headers)["session_token"]; - - if (!token) { - return next(); - } - - const currentSessionCookie = retrieveSessionCookie(token); - - if (!currentSessionCookie) { - return next(); - } - - req.sessionCookie = currentSessionCookie; - res.locals.isLoggedIn = true; - - return next(); -} as Middleware; - -setCookie.priority = 0; - -export const deleteExpiredCookie = function deleteExpiredCookie( - req: express.Request, - res: express.Response, - next: express.NextFunction, -) { - if (req.sessionCookie) { - if (req.sessionCookie.isExpired()) { - removeSessionCookie(req.sessionCookie.email); - res.locals.isLoggedIn = false; - } - } - - return next(); -} as Middleware; - -deleteExpiredCookie.priority = 1; - -declare global { - namespace Express { - interface Locals { - isLoggedIn: boolean; - } - - interface Request { - sessionCookie?: SessionCookie; - } - } -} diff --git a/app/login/login.services.ts b/app/login/login.services.ts deleted file mode 100644 index a00f58f..0000000 --- a/app/login/login.services.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { Cookie, z } from "@deps"; - -const SessionCookieSchema = z - .object({ - value: z.string().default(() => globalThis.crypto.randomUUID()), - expires: z.coerce.date(), - domain: z.string().min(1), - secure: z.boolean().default(true), - email: z.string().email(), - }); - -type SessionCookieRaw = z.input; -type SessionCookieOutput = z.output; - -export interface SessionCookie extends SessionCookieOutput {} - -export class SessionCookie implements Cookie { - static parse(properties: unknown) { - return new SessionCookie(SessionCookieSchema.parse(properties)); - } - - readonly name = "session_token"; - - public constructor(properties: SessionCookieRaw) { - Object.assign(this, SessionCookieSchema.parse(properties)); - } - - public isExpired() { - const now = new Date(); - - return now >= this.expires; - } -} - -export function createSessionCookie( - email: string, - config: Express.Locals["config"], -) { - const now = Date.now(); - const sessionCookie = new SessionCookie({ - expires: new Date(now + config.sessionExpiresIn), - domain: config.domain, - secure: config.environment === "PRODUCTION", - email, - }); - - storeSessionCookie(sessionCookie); - - return sessionCookie; -} - -export function parseSessionCookie(cookie: SessionCookieRaw) { - return SessionCookie.parse(cookie); -} - -export function isAuthorized( - config: Express.Locals["config"], - email: string, - password: string, -) { - const emailIndex = config.adminEmails.indexOf(email); - - if (emailIndex < 0) { - return false; - } - - return config.adminPasswords.includes(password); -} - -export function retrieveSessionCookie(token: string) { - const raw = localStorage.getItem(`session:${token}`); - - if (raw === null) { - return null; - } - - const sessionCookie = SessionCookie.parse(JSON.parse(raw)); - - if (sessionCookie.isExpired()) { - removeSessionCookie(sessionCookie.email); - - return null; - } - - return sessionCookie; -} - -function storeSessionCookie(cookie: SessionCookie) { - removeSessionCookie(cookie.email); - - localStorage.setItem(`user:${cookie.email}`, cookie.value); - localStorage.setItem(`session:${cookie.value}`, JSON.stringify(cookie)); -} - -export function removeSessionCookie(email: string) { - console.log("remove session cookie"); - const token = localStorage.getItem(`user:${email}`); - - if (!token) { - return; - } - - localStorage.removeItem(`session:${token}`); - localStorage.removeItem(`user:${email}`); -} - -export function isTokenStored(token: string) { - return typeof localStorage.getItem(`session:${token}`) === "string"; -} diff --git a/app/login/login.view.ts b/app/login/login.view.ts deleted file mode 100644 index 3b74ba3..0000000 --- a/app/login/login.view.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { EndpointView } from "@common"; -import { Status, z } from "@deps"; -import { createSessionCookie, isAuthorized } from "./login.services.ts"; - -export default EndpointView.init({ - method: "post", - path: "/login", - view: "", - context: z.null().default(null), - payload: { - body: z.object({ - email: z.string().email(), - password: z.string().min(1), - redirectTo: z.string().min(1), - }), - }, -}).registerHandler(function main(req, res) { - const { redirectTo, email, password } = req.body; - - if (req.sessionCookie && !req.sessionCookie.isExpired()) { - return res.redirect(redirectTo); - } - - if (!isAuthorized(res.app.locals.config, email, password)) { - return this.renderNotOk( - Status.Unauthorized, - res, - `User ${email} is not authorized.`, - ); - } - - const sessionCookie = createSessionCookie(email, res.app.locals.config); - - res.cookie(sessionCookie.name, sessionCookie.value, { - expires: sessionCookie.expires, - domain: sessionCookie.domain, - secure: sessionCookie.secure, - }); - - res.locals.isLoggedIn = true; - - return res.redirect(redirectTo); -}); diff --git a/app/login/logout.view.ts b/app/login/logout.view.ts deleted file mode 100644 index a6a28d1..0000000 --- a/app/login/logout.view.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { EndpointView } from "@common"; -import { z } from "@deps"; -import { removeSessionCookie } from "./login.services.ts"; - -export default EndpointView.init({ - method: "post", - path: "/logout", - view: "", - context: z.null().default(null), - payload: { - body: z.object({ - redirectTo: z.string().min(1), - }), - }, -}).registerHandler(function main(req, res) { - const { redirectTo } = req.body; - res.locals.isLoggedIn = false; - - if (!req.sessionCookie) { - return res.redirect(redirectTo); - } - - removeSessionCookie(req.sessionCookie.email); - - return res.clearCookie(req.sessionCookie.name).redirect(redirectTo); -}); diff --git a/app/main.ts b/app/main.ts deleted file mode 100644 index 66abc87..0000000 --- a/app/main.ts +++ /dev/null @@ -1 +0,0 @@ -await import("./server/server.module.ts"); diff --git a/app/server/routers/router.api.ts b/app/server/routers/router.api.ts deleted file mode 100644 index d29f9af..0000000 --- a/app/server/routers/router.api.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { express } from "@deps"; -import { retrieveEndpoints } from "./router.utils.ts"; - -const ApiRouter = express.Router(); -const endpoints = await retrieveEndpoints("api"); - -endpoints.forEach((endpoint) => { - ApiRouter[endpoint.method](endpoint.path, ...endpoint.handlers); -}); - -export default ApiRouter; diff --git a/app/server/routers/router.assets.ts b/app/server/routers/router.assets.ts deleted file mode 100644 index 4744b0f..0000000 --- a/app/server/routers/router.assets.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { express, minifier, Status } from "@deps"; -import serverConfig from "../server.config.ts"; - -const AssetsRouter = express.Router(); - -export enum Asset { - FaviconAndroid192 = "android-chrome-192x192.png", - FaviconAndroid512 = "android-chrome-512x512.png", - FaviconApple = "apple-touch-icon.png", - FaviconIco = "favicon.ico", - FaviconIco16 = "favicon-16x16.png", - FaviconIco32 = "favicon-32x32.png", - Manifest = "site.webmanifest", - ScriptMain = "scripts/main.mjs", - StyleMain = "styles/main.css", - RobotsTxt = "robots.txt", -} - -const PUBLIC_FILES_DIRECTORY = "./app/views/public"; - -export const ASSET_FILES = { - [Asset.FaviconAndroid192]: Deno.readFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.FaviconAndroid192}`, - ), - [Asset.FaviconAndroid512]: Deno.readFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.FaviconAndroid512}`, - ), - [Asset.FaviconApple]: Deno.readFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.FaviconApple}`, - ), - [Asset.FaviconIco]: Deno.readFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.FaviconIco}`, - ), - [Asset.FaviconIco16]: Deno.readFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.FaviconIco16}`, - ), - [Asset.FaviconIco32]: Deno.readFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.FaviconIco32}`, - ), - [Asset.Manifest]: Deno.readTextFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.Manifest}`, - ), - [Asset.ScriptMain]: Deno.readTextFileSync( - `${PUBLIC_FILES_DIRECTORY}/${Asset.ScriptMain}`, - ), - [Asset.StyleMain]: minifier.minify( - minifier.Language.CSS, - Deno.readTextFileSync(`${PUBLIC_FILES_DIRECTORY}/${Asset.StyleMain}`), - ), - [Asset.RobotsTxt]: Deno.readTextFileSync( - `${PUBLIC_FILES_DIRECTORY}/assets/${Asset.RobotsTxt}`, - ), -}; - -if (serverConfig.environment === "DEVELOPMENT") { - ASSET_FILES[Asset.Manifest] = ASSET_FILES[Asset.Manifest].replace( - serverConfig.package.homepage, - `http://localhost:${serverConfig.port}`, - ); -} - -const CONTENT_TYPE_BY_ASSET = { - [Asset.FaviconAndroid192]: "image/png", - [Asset.FaviconAndroid512]: "image/png", - [Asset.FaviconApple]: "image/png", - [Asset.FaviconIco]: "image/x-icon", - [Asset.FaviconIco16]: "image/png", - [Asset.FaviconIco32]: "image/png", - [Asset.Manifest]: "application/json", - [Asset.ScriptMain]: "application/javascript", - [Asset.StyleMain]: "text/css", - [Asset.RobotsTxt]: "text/plain", -}; - -function getHandler( - asset: Asset, - _req: express.Request, - res: express.Response, - _next: express.NextFunction, -) { - const contentType = CONTENT_TYPE_BY_ASSET[asset]; - const content = ASSET_FILES[asset]; - - res.setHeader("content-type", contentType); - - if (typeof content === "string") { - return res.status(Status.OK).send(content); - } - - return res.status(Status.OK).end(content, "binary"); -} - -AssetsRouter.get( - `/${Asset.FaviconAndroid192}`, - getHandler.bind(AssetsRouter, Asset.FaviconAndroid192), -); - -AssetsRouter.get( - `/${Asset.FaviconAndroid512}`, - getHandler.bind(AssetsRouter, Asset.FaviconAndroid512), -); - -AssetsRouter.get( - `/${Asset.FaviconApple}`, - getHandler.bind(AssetsRouter, Asset.FaviconApple), -); - -AssetsRouter.get( - `/${Asset.FaviconIco}`, - getHandler.bind(AssetsRouter, Asset.FaviconIco), -); - -AssetsRouter.get( - `/${Asset.FaviconIco16}`, - getHandler.bind(AssetsRouter, Asset.FaviconIco16), -); - -AssetsRouter.get( - `/${Asset.FaviconIco32}`, - getHandler.bind(AssetsRouter, Asset.FaviconIco32), -); - -AssetsRouter.get( - `/${Asset.Manifest}`, - getHandler.bind(AssetsRouter, Asset.Manifest), -); - -AssetsRouter.get( - `/${Asset.ScriptMain}`, - getHandler.bind(AssetsRouter, Asset.ScriptMain), -); - -AssetsRouter.get( - `/${Asset.StyleMain}`, - getHandler.bind(AssetsRouter, Asset.StyleMain), -); - -AssetsRouter.get( - `/${Asset.RobotsTxt}`, - getHandler.bind(AssetsRouter, Asset.RobotsTxt), -); - -export default AssetsRouter; diff --git a/app/server/routers/router.utils.ts b/app/server/routers/router.utils.ts deleted file mode 100644 index 448672f..0000000 --- a/app/server/routers/router.utils.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { relative, type WalkOptions, walkSync } from "@deps"; -import { - EndpointApi, - type EndpointApiSettings, - EndpointView, - type EndpointViewSettings, -} from "@common"; - -const ENDPOINT_CONSTRUCTOR = { - api: isEndpointApi, - view: isEndpointView, -}; - -interface EndpointConstructorMap { - api: EndpointApi>; - view: EndpointView>; -} - -const ROOT_DIR = "./app"; - -const walkOptions: WalkOptions = { - skip: [/core\//, /views\//], - includeDirs: false, - exts: [".ts"], - match: [/\.endpoint|\.view/], -}; - -export async function retrieveEndpoints< - K extends keyof typeof ENDPOINT_CONSTRUCTOR, ->(kind: K): Promise { - const isValid = ENDPOINT_CONSTRUCTOR[kind]; - const endpoints = []; - - for (const entry of walkSync(ROOT_DIR, walkOptions)) { - const path = getRelativePath(entry.path); - const def = await getDefaultExport(path); - - if (isValid(def)) { - endpoints.push(def); - } - } - - return endpoints as EndpointConstructorMap[K][]; -} - -function getRelativePath(path: string) { - const currentFile = import.meta.url.replace(`file://${Deno.cwd()}/app/`, ""); - - return relative(currentFile, `./${path}`); -} - -async function getDefaultExport(path: string) { - const value = await import(path); - - return value.default; -} - -function isEndpointApi( - value: unknown, -): value is EndpointApi> { - return value instanceof EndpointApi; -} - -function isEndpointView( - value: unknown, -): value is EndpointView> { - return value instanceof EndpointView; -} diff --git a/app/server/routers/router.webpage.ts b/app/server/routers/router.webpage.ts deleted file mode 100644 index 9696eea..0000000 --- a/app/server/routers/router.webpage.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { express } from "@deps"; -import { retrieveEndpoints } from "./router.utils.ts"; - -const WebpageRouter = express.Router(); -const endpoints = await retrieveEndpoints("view"); - -endpoints.forEach((endpoint) => { - WebpageRouter[endpoint.method](endpoint.path, ...endpoint.handlers); -}); - -export default WebpageRouter; diff --git a/app/server/server.config.ts b/app/server/server.config.ts deleted file mode 100644 index 854bb66..0000000 --- a/app/server/server.config.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { z } from "@deps"; -import { Logger } from "@common"; -import packageJson from "../../package.json" assert { type: "json" }; - -enum Environment { - Development = "DEVELOPMENT", - Production = "PRODUCTION", -} - -const EnvironmentSchema = z.preprocess( - (x) => (typeof x === "string" ? x.toUpperCase() : x), - z.nativeEnum(Environment).default(Environment.Production), -); - -const ServerConfigSchema = z - .object({ - environment: EnvironmentSchema, - port: z.coerce.number().int().default(3000), - loggerSeverity: z.custom< - (typeof Logger.Severity)[keyof typeof Logger.Severity] - >(), - loggerMode: z.enum(["pretty", "json"]), - package: z.custom().default(packageJson), - dbUri: z.string().url(), - sessionExpiresIn: z.number().int().default(180_000), - location: z.string().url(), - domain: z.string().default(""), - adminEmails: z.array(z.string().email()), - adminPasswords: z.array(z.string().min(6)), - }) - .transform((config) => { - if (!config.domain) { - config.domain = new URL(config.location).hostname; - } - - return config; - }); - -const environment = EnvironmentSchema.parse(Deno.env.get("ENVIRONMENT")); - -const configurations = { - [Environment.Development]: ServerConfigSchema.parse({ - environment: Environment.Development, - port: Deno.env.get("PORT"), - loggerSeverity: Logger.Severity.Debug, - loggerMode: "pretty", - dbUri: Deno.env.get("MONGODB_URI") || "mongodb://localhost", - location: Deno.env.get("LOCATION"), - adminEmails: Deno.env.get("ADMIN_EMAILS")?.split(","), - adminPasswords: Deno.env.get("ADMIN_PASSWORDS")?.split(","), - }), - [Environment.Production]: ServerConfigSchema.parse({ - environment: Environment.Production, - port: Deno.env.get("PORT"), - loggerSeverity: Logger.Severity.Informational, - loggerMode: "json", - dbUri: Deno.env.get("MONGODB_URI"), - location: Deno.env.get("LOCATION"), - adminEmails: Deno.env.get("ADMIN_EMAILS")?.split(","), - adminPasswords: Deno.env.get("ADMIN_PASSWORDS")?.split(","), - }), -}; - -export default configurations[environment]; diff --git a/app/server/server.middlewares.ts b/app/server/server.middlewares.ts deleted file mode 100644 index baf36b9..0000000 --- a/app/server/server.middlewares.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { difference, type express, expressHandlebars, Status } from "@deps"; -import { retrieveHelpers } from "./server.utils.ts"; - -export function inspectRequest( - req: express.Request, - res: express.Response, - next: express.NextFunction, -) { - req.id = globalThis.crypto.randomUUID(); - res.duration = 0.1; - - const start = new Date(); - - res.on("finish", function handleFinish() { - const end = new Date(); - res.duration = difference(start, end, { units: ["milliseconds"] }) - .milliseconds as number; - - res.app.logger.http(req, res); - }); - - next(); -} - -export const handlebars = expressHandlebars.create({ - extname: "hbs", - helpers: await retrieveHelpers(), -}); - -export function handleNotFound(req: express.Request, res: express.Response) { - return res.status(Status.NotFound).render("not-found", { path: req.path }); -} - -declare global { - namespace Express { - interface Request { - id: string; - } - - interface Response { - duration: number; - } - } -} diff --git a/app/server/server.module.ts b/app/server/server.module.ts deleted file mode 100644 index 0bf4434..0000000 --- a/app/server/server.module.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { express, type mongodb } from "@deps"; -import { - createLogger, - DatabaseClient, - type FanfictionDocumentOutput, - type Logger, -} from "@common"; -import serverConfig from "./server.config.ts"; -import * as middleware from "./server.middlewares.ts"; -import AssetsRouter from "./routers/router.assets.ts"; -import WebpageRouter from "./routers/router.webpage.ts"; -import ApiRouter from "./routers/router.api.ts"; -import { retrieveMiddlewares } from "./server.utils.ts"; - -const application = express(); - -application.logger = createLogger({ - application: serverConfig.package.name, - version: serverConfig.package.version, - severity: serverConfig.loggerSeverity, - environment: serverConfig.environment, - mode: serverConfig.loggerMode, - inspectOptions: { - colors: true, - showHidden: true, - showProxy: true, - getters: true, - }, -}); - -const database = new DatabaseClient(serverConfig.dbUri, { - logger: application.logger, -}).registerCollection("fanfictions"); - -application.use(middleware.inspectRequest); -application.use(...(await retrieveMiddlewares())); - -application.disable("x-powered-by"); -application.engine(".hbs", middleware.handlebars.engine); -application.set("view engine", ".hbs"); -application.set("views", "./app/views"); - -application.locals.title = "Duofiction"; -application.locals.config = serverConfig; - -application.use(express.urlencoded({ extended: true })); -application.use(express.json()); -application.use(AssetsRouter); -application.use(WebpageRouter); -application.use("/api", ApiRouter); -application.use(middleware.handleNotFound); - -await database.connect(); - -const server = application.listen(serverConfig.port, handleListening); - -server.on("close", handleClose); - -Deno.addSignalListener("SIGTERM", handleKillSignal); -Deno.addSignalListener("SIGINT", handleKillSignal); - -function handleListening() { - application.fanfics = database.fanfictions; - application.logger.info(`Listening on port ${serverConfig.port}.`); -} - -async function handleClose() { - await database.close(false); - - application.logger.info("Shutting down application."); -} - -async function handleKillSignal() { - console.log(); - - await server.close(); -} - -declare global { - namespace Application { - type FanfictionsCollection = mongodb.Collection; - - type FindFanfictionOptions = mongodb.FindOptions; - } - - namespace Express { - interface Application { - fanfics: (typeof database)["fanfictions"]; - logger: Logger; - } - - interface Locals { - config: typeof serverConfig; - } - } -} diff --git a/app/server/server.utils.ts b/app/server/server.utils.ts deleted file mode 100644 index 735cdca..0000000 --- a/app/server/server.utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { relative, type WalkOptions, walkSync } from "@deps"; -import { isFunction, isMiddleware, type Middleware } from "@common"; - -const ROOT_DIR = "./app"; - -const walkHelperOptions: WalkOptions = { - skip: [/server\//, /views\//], - includeDirs: false, - exts: [".ts"], - match: [/\.helpers/], -}; - -const walkMiddlewareOptions: WalkOptions = { - skip: [/server\//, /views\//], - includeDirs: false, - exts: [".ts"], - match: [/\.middlewares\./], -}; - -export async function retrieveHelpers() { - const helpers = [] as CallableFunction[]; - - for (const entry of walkSync(ROOT_DIR, walkHelperOptions)) { - const path = getRelativePath(entry.path); - const namedExports = await getNamedExports(path); - - helpers.push(...namedExports.filter(isFunction)); - } - - return helpers.reduce((acc, prev) => { - acc[prev.name] = prev; - - return acc; - }, {} as Record); -} - -export async function retrieveMiddlewares() { - const middlewares = [] as Middleware[]; - - for (const entry of walkSync(ROOT_DIR, walkMiddlewareOptions)) { - const path = getRelativePath(entry.path); - const namedExports = await getNamedExports(path); - - middlewares.push(...namedExports.filter(isMiddleware)); - } - - return middlewares.sort((a, b) => a.priority - b.priority); -} - -function getRelativePath(path: string) { - const currentFile = import.meta.url.replace(`file://${Deno.cwd()}/app/`, ""); - - return relative(currentFile, `./${path}`); -} - -async function getNamedExports(path: string) { - const module = await import(path); - - return Object.values(module); -} diff --git a/app/views/catalog-paginated.hbs b/app/views/catalog-paginated.hbs deleted file mode 100644 index fe471bd..0000000 --- a/app/views/catalog-paginated.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{#each fanfictions}} - {{> fanfiction-card . }} -{{/each}} - \ No newline at end of file diff --git a/app/views/catalog-summary.hbs b/app/views/catalog-summary.hbs deleted file mode 100644 index 918a3de..0000000 --- a/app/views/catalog-summary.hbs +++ /dev/null @@ -1,13 +0,0 @@ -

- Recently Added -

-{{#each recentlyAdded}} - {{> fanfiction-card . }} -{{/each}} - -

- Recently Updated -

-{{#each recentlyUpdated}} - {{> fanfiction-card . }} -{{/each}} \ No newline at end of file diff --git a/app/views/catalog-tags.hbs b/app/views/catalog-tags.hbs deleted file mode 100644 index def61cf..0000000 --- a/app/views/catalog-tags.hbs +++ /dev/null @@ -1,3 +0,0 @@ -{{#each tags}} - {{> tag-button }} -{{/each}} \ No newline at end of file diff --git a/app/views/error.hbs b/app/views/error.hbs deleted file mode 100644 index 541630f..0000000 --- a/app/views/error.hbs +++ /dev/null @@ -1,2 +0,0 @@ -

{{status}}

-

{{message}}

\ No newline at end of file diff --git a/app/views/layouts/main.hbs b/app/views/layouts/main.hbs deleted file mode 100644 index 430b12f..0000000 --- a/app/views/layouts/main.hbs +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - {{#if subtitle}} - {{subtitle}} - {{title}} - {{else}} - {{title}} - {{/if}} - - - - - - - - - - - - - -
{{{body}}}
- {{> login-modal }} - - - - - \ No newline at end of file diff --git a/app/views/not-found.hbs b/app/views/not-found.hbs deleted file mode 100644 index 9ca844a..0000000 --- a/app/views/not-found.hbs +++ /dev/null @@ -1,2 +0,0 @@ -

404

-

Oops! Page {{path}} not found

\ No newline at end of file diff --git a/app/views/partials/fanfiction-card.hbs b/app/views/partials/fanfiction-card.hbs deleted file mode 100644 index 378a9c9..0000000 --- a/app/views/partials/fanfiction-card.hbs +++ /dev/null @@ -1,33 +0,0 @@ - \ No newline at end of file diff --git a/app/views/partials/login-modal.hbs b/app/views/partials/login-modal.hbs deleted file mode 100644 index adde9ff..0000000 --- a/app/views/partials/login-modal.hbs +++ /dev/null @@ -1,32 +0,0 @@ - \ No newline at end of file diff --git a/app/views/partials/tag-button.hbs b/app/views/partials/tag-button.hbs deleted file mode 100644 index 34511c9..0000000 --- a/app/views/partials/tag-button.hbs +++ /dev/null @@ -1,12 +0,0 @@ -{{#if total}} - - {{name}}:{{value}} - {{total}} - -{{else}} - - {{name}}:{{value}} - -{{/if}} \ No newline at end of file diff --git a/app/views/public/assets/android-chrome-192x192.png b/app/views/public/assets/android-chrome-192x192.png deleted file mode 100644 index 6291735c125249ca81d65c61784132e5012e5b5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6657 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 diff --git a/app/views/public/assets/android-chrome-512x512.png b/app/views/public/assets/android-chrome-512x512.png deleted file mode 100644 index eb5c51708faaf5e7654681377bb4c723ef2863dc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 23153 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 diff --git a/app/views/public/assets/apple-touch-icon.png b/app/views/public/assets/apple-touch-icon.png deleted file mode 100644 index ec1af68c624582d0fc90c2543022324eb38b2dde..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5921 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∋0 zosc2O`#v%8wmWa*0OXTq(#{INNjY5UCoB$#cg1iEEb3leZ9eY~5qA=Y@8yn5^^Od! zJ!DBu?3M-w;fwDQ^E%ACnLkfEu8%2nOH7!+Tc?zW%<8zkC69`&lDcM`+|8v+r?Ybd zca+8OHpk>zDCG#CUJHm<@#NXgNAR$Ha1Z=Q#Nl{?YnFQT*0$)93#|zEEfE%xkZ28l hv+M88w7>cfS84vh1zb~rrMUrs7b-86E0tb_{|~n>VGRHP diff --git a/app/views/public/assets/favicon-16x16.png b/app/views/public/assets/favicon-16x16.png deleted file mode 100644 index bc5b88fc88609dc5a24b97f32e6b7e15b2af61e2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 471 zcmV;|0Vw{7P)Px$kx4{BR5(walS?lHQ547jry3+4^{S$)A(51gU_q={*a-^@3o8p>fVHIi@&zoc z_yjBvD~XVFVc`*vHbiSC9f?QHbZX38nF+TulXjZ8yYtBJo^$@^(D}_%R5!kM2^fOs zF$P@Tr7<}+yk|z*<^>|nn?uRVR2+mgZ@{u00LwZsBki+Z2qmxY6JWGb9bhrAZ)$CG zf>5f210J&?-t!9QbhL)h-5LICp~SZ#A9I2r*zItDVHo%}Or97F)}SU3z{PC}L$Q9; z*VXbEV{9uXM3-EEjFiKtqVlAc<|cH7+mTmPi1G_e2OOZ&#hIyb7eG-9DC&B-QE;DC zQK+dAL^v6V^-Z_{FRyPXXvM04o=EtA05xkskqr9-T{Fdpq`X6*O+G}w7BUxaxZ0I1 z8QqVu1P6GB=*&zVy&|73DfAa3u=R>EIsw^?gp=(wf=YwaZGzEq#YOT?QxZkUx!kXt z&Nkw!;{;;9t6WJEw~Ri=Px&QAtEWR9Hv7mrG0&VGzgv^R*OEP&7OWiXa&Ag$ZhWa52QACl6@i(P%sv9~kti z;K_)I5N;-V_a-Eom>A_s445d`5G=H4X|cibl0J5K%&ruH-R^^;b7{KYJbvGNvore< zmZbml=oKO84uCxXDE-{*ml+rs@}X!;0iSHB9|Vi$DsPeBj%`{;3pDV>e_dhja$>0J zrUAAV7T{FFArL6Y5KT@X=moQ@*5{vOMoG2+syzWgMyjdG>$Q~$>i<0E6+)SG8r&!Y zX&=l864`^G0iUlzQF6X-B*2od0K?T(?JXKJ^ZhRY)VP)qI)K^v1%#%e02^})L8#tQ z4v!~CSA<3u3EXGYUpzb~`{)rHN&dkDF zydAqLyrvKexcx}o?t7*HlT*<@HDDrpt1D4huvybcNfJiJf`})Q)-MvF>uU4ao2CG& zqv+2V)JzzO5JKCn%jH^LRul!(GqV~*Cc7o&C+E&IwwMA~ZzBs0qQ}Sf*R+}fEc{wT zBs!hxEp!w;KQ9**rNw`3>;S02zlEn919u19ISYxuDj} ze+OV0kmC9xPA*xRM@2Rc2xWCqQ`E(R1Ox;|~(6{7yw8m{)?Y1z5Zz_{atq!=X+xW0 z*axgW$*cg>_3&f}v6#Lfg`m#%)@!#+hmoBC>(;S53)UUL%FqZPn~RB6ytV5DdUW(a z&D~W;kj>T1DS*Pbbo^lL^Hl)qpASQ$H$c?s)S(l70)`VY&#}hF%0(T31~Kfv46vFW t+t+hD01TcZqTXDIZr9h>Xd`%~{03;~62T-56*K?<002ovPDHLkV1n0nnjHWD diff --git a/app/views/public/assets/favicon.ico b/app/views/public/assets/favicon.ico deleted file mode 100644 index fafcbdd336ac7ab3a6d5289be04c48b94fa1b159..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15406 zcmeHOeQaA-6@Q}}YzU-H0s)1gz#p(bCj|V5sDuz3#My*ewxKbt+ExmzV@aGYE7j^2 z!KNf#7uJj=?b5Z&$84uv(xmxvW9QRJ9NTH)_#;gm+bOHvFK8Z0)6jFKUXtFe`8!k`D9%u&rFaZDxP$88THF+_6AXEdGR3Fke4NEQCStwKjR&M$aviG&+an_U zCUYgv?;Gg`ci#t+G<2#F`YY-(4~Zart$cX~$oHyeLdejh7;yuV#n-&<=cRLkb^G#BmN%E}K<4g;;Zaz2CDZ@q_W zRJEIzO{JK8y|y4Y=z`O~d07(%G5O@L40hX)teUB(z837;_o~IP)MP6C3fun^gXeUO zKR;*BzEutO9WTjZuosz^u>X&X=|A=V?9?bkrri3;KLTO*z&e_7IO>`oDY?I?=+D`; zm~Foy@>j6W&3Kmm$`#*XL52O#wg35Xppy0-LVZ>vKNtODb#}ae#V^;;ej#3?@71(l z$^MMF+JJm|AvPLk+UKaSTSrLeg%_)TYV7Z{$CZ^oJ3R`Hwv+1Sti85cD4XtC`AX|g z&^MTsO?@!bW6Q`Mb+$n)GMU-a(7r~DVzd5(O7gS)Dz)~m=o0^3SIaTn3!Q}P$@uDC zNVqTJ@00QPwQGx8mEwQ_-=_`rYN6BOXE1RC#{#Ww9T<4lF z7T{hU16A_l@sCsS?}*73lh607s#00Ltn56?SCmcibJ~AMwLck8q*96RQvFI-PWvxW z`HQM$^Vk-`{ynP3o0)s$9}D$AomxzMtg_v_Y`&bI@jQnZwC%rn`Q6qfoMS${vUv>u zk0r%PFht8ejie?{f}e5o0nh!>8BaOJXJ?Q;)1^DCE@^!ZdOtzgj``{FM<0cXty7lh@|C*cnmK1(!8^-DAOu*3ipn-ky>D<)l52WzBG4@A#W}v(^exMQc z#=`!eO5vy2%or$YbY*mV0-T%TvTJg_ftc1=d zg>efb@85g-H8@oMhvk@XDaM3lsPZp+O#gUz&lhYvET0E*hXj|R{Xt(;YrI(Ld(Ti8 z{B7U!aCo#g{WwWWHi8G{1~(`4Tll5x>Ffug~p(>eu$ckzIe%92XYE zXg{$RmOe}IdMW(4reUmn0Q?w#F^arHotZgW{rl&k`O#9@Sf&80_;-cow@z$(`fe%w zA^#9`wH!B8|63c5LTyP|?$|TA0cEwe9mV%Z;-4JK#2Jm8v+XC2g7wLA@%|LaSCAhA zdHke5GzQano=>qCBgos`4lO%22EP%07hA{d#0Yek9md>_G6TjyzW+%!#V~0MjHHRP zZP5ChCGYqt7U%3}gq{yh7)uzt@2IbVmZ$dR)CRk$?Dg7RyY7;jzd^K}dG#OOISMU1 z_lxEU$(?B{;wdL*1oNyYS0*1cXlnvjWgYY#s*$C(ot92(QRzEG zc9Xfx{rj+{ROlkQ&8y|W(t`JTUG>w5R;OFt2M}K(U%~0yWuhoB}yiLk2kZLd0N59j)TB}bu<#GOux0h@w zWj{kY{AW6Eycif;*9P*V==}J6+bLaZdRJ@xFIe3Cvm|~U`aCd_K#${DIc5Fp9^E>v)IWOoWMO~coOqa&_7f|Sm$S9zw?&GKPi`)Y(1T) zQ+|Wuc{+ctzFG#-m!se2^@S~^^e5YoI1WnjknHawu?oUX_=hl#rx|-Pp1yH4u^j-m zu(v80uP1&;`yH}1 z!m-nXpYkGZq+*XrVHUoZ!ml-cOEzX|tS{qp=Y&6*K%H5948WD~?}b>!?xpZ&F^K52 z>ff~L4`RQ5CGn&fPN{dB1%En~h+j*^9+$!?^?ozpU!JRrkE_)gVO#Qgey2u%uAt6b zPW(=YNy~j+&rcQc#}nymi<)(YZC~EbujTM#zoXtf0WNiRhQ%y=ru+f?pUaqPyQ0=6 zN#IBOp8hDAcuI&N*ZakY|A(0OVQl_}2!6_CQnRk=X%C-2gLx>%KuZt&4{7MVLI3~& diff --git a/app/views/public/assets/robots.txt b/app/views/public/assets/robots.txt deleted file mode 100644 index 77470cb..0000000 --- a/app/views/public/assets/robots.txt +++ /dev/null @@ -1,2 +0,0 @@ -User-agent: * -Disallow: / \ No newline at end of file diff --git a/app/views/public/assets/site.webmanifest b/app/views/public/assets/site.webmanifest deleted file mode 100644 index f67b428..0000000 --- a/app/views/public/assets/site.webmanifest +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "Duofiction", - "short_name": "Duofiction", - "icons": [ - { - "src": "/android-chrome-192x192.png", - "sizes": "192x192", - "type": "image/png" - }, - { - "src": "/android-chrome-512x512.png", - "sizes": "512x512", - "type": "image/png" - } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone", - "start_url": "https://duofiction.deno.dev" -} diff --git a/app/views/public/scripts/main.mjs b/app/views/public/scripts/main.mjs deleted file mode 100644 index d21c760..0000000 --- a/app/views/public/scripts/main.mjs +++ /dev/null @@ -1,10 +0,0 @@ -/// -import $ from "https://esm.sh/jquery@3.7.1"; - -$("#login-form").on("submit", function handleSubmitLogin() { - $("#redirect-to").attr("value", window.location.pathname); -}); - -$("#logout-form").on("submit", function handleClickLogout() { - $("#logout-redirect-to").attr("value", window.location.pathname); -}); diff --git a/app/views/public/styles/main.css b/app/views/public/styles/main.css deleted file mode 100644 index 6e21938..0000000 --- a/app/views/public/styles/main.css +++ /dev/null @@ -1,45 +0,0 @@ -:root { - --bs-success: var(--bs-emerald-700); -} - -.pagination { - --bs-pagination-active-bg: var(--bs-emerald-700); -} - -nav.navbar { - margin-bottom: 2em; -} - -nav.navbar form { - margin-bottom: 0; -} - -.navbar-nav { - align-items: center; -} - -article.card { - margin-bottom: 2em; -} - -.with-translation span:nth-of-type(1) { - font-size: x-large; -} - -.with-translation span:nth-of-type(2) { - font-size: medium; -} - -.btn.tag { - font-family: "Victor Mono", Monaco, Lucida, monospace; - font-size: smaller; -} - -.btn.tag.counter { - margin-right: 1em; - margin-top: 1em; -} - -.card .translation { - font-size: small; -} \ No newline at end of file diff --git a/common/core/deps.ts b/common/core/deps.ts deleted file mode 100644 index 106cf8f..0000000 --- a/common/core/deps.ts +++ /dev/null @@ -1,3 +0,0 @@ -import "std/dotenv/load.ts"; - -export { z } from "zod"; diff --git a/common/core/utility-types.ts b/common/core/utility-types.ts deleted file mode 100644 index 46440d2..0000000 --- a/common/core/utility-types.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Instead of adding a `disable` directive, use this value - * to indicate that an any type is expected that way purposely. - */ -// deno-lint-ignore no-explicit-any -export type SafeAny = any; - -/** - * Represents any possible array. - */ -export type AnyArray = Array; - -/** - * Takes a type T and expands it to an object type with all properties set to their original types. - * - * @example - * ```ts - * // On hover: interface Person - * interface Person { - * name: string; - * age: number; - * } - * - * // On hover: type ExpandedPerson = { name: string; age: number } - * type ExpandedPerson = Expand; - * ``` - */ -export type Expand = T extends infer O ? { [K in keyof O]: O[K] } : never; - -/** - * Recursively expands a type T to an object type with all nested properties mapped to their original types. - * This is like {@link Expand} but works recursively to expand nested object properties. - * - * @example - * ```ts - * // On hover: interface Person - * interface Person { - * name: string; - * address: { - * street: string; - * city: string; - * } - * } - * - * // On hover: type RecursivePerson = { - * // name: string; - * // address: { - * // street: string; - * // city: string; - * // } - * // } - * type RecursivePerson = ExpandRecursively; - * ``` - */ -export type ExpandRecursively = T extends object - ? T extends infer O ? { [K in keyof O]: ExpandRecursively } - : never - : T; - -/** - * Checks if a type T is an actual object. It excludes - * `Date` and `Array` types. - */ -export type IsNativeObject = T extends Date ? false - : T extends Array ? false - : T extends Record ? true - : false; - -/** - * Gets the names of properties on type T that are native object types. - */ -export type ObjectPropertyNames = { - [K in keyof T]: IsNativeObject extends true ? K : never; -}[keyof T]; - -/** - * Gets the names of properties on type T that are **NOT** native object types. - */ -export type NonObjectPropertyNames = { - [K in keyof T]: IsNativeObject extends true ? never : K; -}[keyof T]; - -/** - * Constructs a new type by picking only the properties from `T` - * that are native object types. - */ -export type ObjectProperties = Pick>; - -/** - * Joins two types into a dot-delimited string literal type. - * - * @example - * ```ts - * type A = "foo"; - * type B = "bar"; - * - * type AB = JoinWithDot; - * // "foo.bar" - * ``` - */ -export type JoinWithDot< - K extends string | number, - P extends string | number, -> = `${K}.${P}`; - -/** - * Splits a dot-delimited string literal type into separate types. - * - * @example - * ```ts - * type AB = "a.b"; - * - * type A = SplitDotted[0]; // "a" - * type B = SplitDotted[1]; // "b" - * ``` - */ -export type SplitDotted = T extends string ? SplitText : never; - -/** - * Splits a string into an array by a delimiter. - * - * @example - * ```ts - * type Parts = SplitText<"a.b.c", "."> // ['a', 'b', 'c'] - * ``` - */ -export type SplitText = string extends S - ? string[] - : S extends "" ? [] - : S extends `${infer T}${D}${infer U}` ? [T, ...SplitText] - : [S]; - -/** - * Gets the tail of a tuple type by removing the first element. - * - * @example - * ```ts - * type Tuple = [1, 2, 3]; - * type Tail = Tail; // [2, 3] - * ``` - */ -export type Tail = T extends [infer _FirstItem, ...infer Rest] ? Rest - : never; - -/** - * Checks if the given array type T has a length of `0`. - */ -export type HasLengthZero = T extends Array - ? T["length"] extends 0 ? true - : false - : never; - -/** - * Checks if the given array type T has a length of `1`. - */ -export type HasLengthOne = T extends Array - ? T["length"] extends 1 ? true - : false - : never; - -/** - * Gets the type of the property K from type T. - * - * @example - * ```ts - * interface Person { - * name: string; - * age: number; - * } - * - * type Name = GetPropertyType; // string - * ``` - */ -export type GetPropertyType = K extends keyof T ? T[K] : never; - -/** - * Gets the type of a nested property path on a type T. - * - * @example - * ```ts - * interface Person { - * name: string; - * address: { - * street: string; - * } - * } - * - * type Street = GetNestedPropertyType; - * // string - * ``` - */ -export type GetNestedPropertyType< - T, - Path extends AnyArray, -> = HasLengthOne extends true ? GetPropertyType - : GetNestedPropertyType, Tail>; - -/** - * Converts a union type into an intersection type. - * - * @example - * ```ts - * type A = string | number; - * type B = UnionToIntersection; // string & number - * ``` - */ -export type UnionToIntersection = ( - U extends unknown ? (arg: U) => 0 : never -) extends (arg: infer I) => 0 ? I - : never; - -/** - * Gets the last type in a union type U. - * - * @example - * ```ts - * type A = string | number; - * type B = LastInUnion; // number - * ``` - */ -export type LastInUnion = UnionToIntersection< - U extends unknown ? (x: U) => 0 : never -> extends (x: infer L) => 0 ? L - : never; - -/** - * Converts a union type into a tuple of its members. - * - * @example - * ```ts - * type A = string | number; - * type B = UnionToTuple; // [string, number] - * ``` - */ -export type UnionToTuple> = [U] extends [never] ? [] - : [...UnionToTuple>, Last]; - -/** - * Converts a type T into a tuple of its keys. - * - * @example - * ```ts - * interface Person { - * name: string; - * age: number; - * } - * - * type Keys = GetKeys; // ['name', 'age'] - * ``` - */ -export type GetKeys = UnionToTuple; - -/** - * Concatenates two tuple types T and U into a new tuple. - * - * @example - * ```ts - * type A = [1, 2]; - * type B = [3, 4]; - * - * type C = Concat; // [1, 2, 3, 4] - * ``` - */ -export type Concat = [...T, ...U]; - -/** - * Converts a type T into an object type with dot-notation keys. - * - * The keys are generated from the nested object structure of T. - * - * @example - * - * ```ts - * interface Person { - * name: string; - * address: { - * street: string; - * } - * } - * - * type PersonDotNotation = ToDotNotation; - * - * // type PersonDotNotation = { - * // name: string; - * // address: { - * // street: string; - * // }; - * // "address.street": string; - * // } - * ``` - */ -export type ToDotNotation = { - [K in DotNotationPathOf]: DotNotationDataTypeOf; -}; - -/* -------------------------------------------------------------------------- */ -/* Internal Types */ -/* -------------------------------------------------------------------------- */ -// #region -type ArrayDotNotation = T extends Array - ? `${number}.${ArrayDotNotation}` - : T extends Record ? `.${DotNotationPathOf}` - : never; - -type DotNotationPathOf = { - [K in keyof T & string]: T[K] extends Array - ? K | `${K}.${number}` | `${K}.${number}.${ArrayDotNotation}` - : T[K] extends Record - // @ts-ignore: ¯\_(ツ)_/¯ - ? `${K}` | `${K}.${DotNotationPathOf}` - : K; -}[keyof T & string]; - -type DotNotationDataTypeOf< - T, - P extends DotNotationPathOf | string, -> = P extends `${infer K}.${infer R}` - // @ts-ignore: ¯\_(ツ)_/¯ - ? DotNotationDataTypeOf - : P extends `${infer K}` - // @ts-ignore: ¯\_(ツ)_/¯ - ? T[K] - : never; -// #endregion