commit
a77af2a579
@ -0,0 +1,5 @@ |
|||||||
|
DISCORD_BOT_TOKEN= |
||||||
|
DISCORD_PUBLIC_KEY= |
||||||
|
DISCORD_CLIENT_SECRET= |
||||||
|
DISCORD_APP_ID= |
||||||
|
HTTP_PORT=5244 |
@ -0,0 +1,4 @@ |
|||||||
|
/build/ |
||||||
|
/node_modules/ |
||||||
|
.env |
||||||
|
/runtime/ |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,26 @@ |
|||||||
|
{ |
||||||
|
"name": "vore-gacha", |
||||||
|
"version": "0.0.1", |
||||||
|
"dependencies": { |
||||||
|
"axios": "^0.24.0", |
||||||
|
"detritus-client": "^0.16.3", |
||||||
|
"detritus-client-rest": "^0.10.5", |
||||||
|
"discord-api-types": "^0.25.2", |
||||||
|
"dotenv": "^10.0.0", |
||||||
|
"fastify": "^3.24.1", |
||||||
|
"pug": "^3.0.2", |
||||||
|
"simple-oauth2": "^4.2.0", |
||||||
|
"slash-create": "^4.4.0" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/node": "^16.11.12", |
||||||
|
"@types/pug": "^2.0.5", |
||||||
|
"@types/simple-oauth2": "^4.1.1", |
||||||
|
"typescript": "^4.5.3" |
||||||
|
}, |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"build": "tsc --build", |
||||||
|
"start": "node build/app.js" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,88 @@ |
|||||||
|
import {SavedWebhook} from "./SavedWebhook.js"; |
||||||
|
import {FastifyReply, FastifyRequest} from "fastify"; |
||||||
|
import {AccessToken, AuthorizationCode, ModuleOptions} from "simple-oauth2"; |
||||||
|
import pug from "pug"; |
||||||
|
import {renderError} from "./PugRenderer.js"; |
||||||
|
import {APIWebhook} from "discord-api-types"; |
||||||
|
import {getBaseUrl, getFirstValue, RouteWithQuerystring} from "./FastifyHelpers.js"; |
||||||
|
import { join } from "path"; |
||||||
|
|
||||||
|
interface DiscordWebhookToken extends AccessToken { |
||||||
|
token: {webhook?: APIWebhook} |
||||||
|
} |
||||||
|
|
||||||
|
const AuthConfig: ModuleOptions["auth"] = { |
||||||
|
tokenHost: "https://discord.com", |
||||||
|
authorizePath: "/api/oauth2/authorize", |
||||||
|
tokenPath: "/api/oauth2/token", |
||||||
|
revokePath: "/api/oauth2/token/revoke", |
||||||
|
} |
||||||
|
|
||||||
|
export class DiscordWebhookHandler { |
||||||
|
readonly webhook: SavedWebhook |
||||||
|
readonly templateFilename: string |
||||||
|
readonly config: ModuleOptions |
||||||
|
readonly destinationFunc: () => string |
||||||
|
constructor({webhook, templateFilename, appId, secret, destinationFunc}: {webhook: SavedWebhook, templateFilename: string, appId: string, secret: string, destinationFunc: () => string}) { |
||||||
|
this.templateFilename = templateFilename |
||||||
|
this.webhook = webhook |
||||||
|
this.config = { |
||||||
|
client: { |
||||||
|
id: appId, |
||||||
|
secret, |
||||||
|
}, |
||||||
|
auth: AuthConfig, |
||||||
|
} |
||||||
|
this.destinationFunc = destinationFunc |
||||||
|
} |
||||||
|
async handleRequest(req: FastifyRequest<RouteWithQuerystring>, res: FastifyReply): Promise<void> { |
||||||
|
const baseUrl = getBaseUrl(req) |
||||||
|
const code = getFirstValue(req.query["code"]) ?? null |
||||||
|
const client = new AuthorizationCode(this.config) |
||||||
|
if (code === null) { |
||||||
|
const authUrl = client.authorizeURL({ |
||||||
|
scope: ["applications.commands", "webhook.incoming"], |
||||||
|
redirect_uri: req.url, |
||||||
|
}) |
||||||
|
res.code(200) |
||||||
|
res.type("text/html") |
||||||
|
res.send(pug.renderFile(join("static/pages", this.templateFilename), {authUrl, baseUrl, isReset: this.webhook.isPresent})) |
||||||
|
return |
||||||
|
} |
||||||
|
let token: DiscordWebhookToken |
||||||
|
try { |
||||||
|
token = await client.getToken({ |
||||||
|
code, |
||||||
|
scope: ["applications.commands", "webhook.incoming"], |
||||||
|
redirect_uri: getBaseUrl(req) + "setup/gameChannel" |
||||||
|
}) as DiscordWebhookToken |
||||||
|
} catch (e) { |
||||||
|
return renderError({ |
||||||
|
baseUrl, |
||||||
|
res: res, |
||||||
|
error: e, |
||||||
|
context: "exchanging the code you gave me for a token" |
||||||
|
}) |
||||||
|
} |
||||||
|
if (!token.token.webhook) { |
||||||
|
return renderError({ |
||||||
|
baseUrl, |
||||||
|
res: res, |
||||||
|
error: "token did not contain webhook", |
||||||
|
context: "processing the token I received" |
||||||
|
}) |
||||||
|
} |
||||||
|
const wasSet = this.webhook.isPresent |
||||||
|
try { |
||||||
|
await this.webhook.replaceHook(token.token.webhook) |
||||||
|
} catch (e) { |
||||||
|
return renderError({ |
||||||
|
baseUrl, |
||||||
|
res: res, |
||||||
|
error: e, |
||||||
|
context: `saving the new webhook${wasSet ? " and deleting the old one" : ""}` |
||||||
|
}) |
||||||
|
} |
||||||
|
res.redirect(this.destinationFunc()) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,19 @@ |
|||||||
|
/** Constructs the base URL from the headers from the given request. */ |
||||||
|
import {FastifyRequest} from "fastify"; |
||||||
|
import {RouteGenericInterface} from "fastify/types/route"; |
||||||
|
|
||||||
|
export interface RouteWithQuerystring extends RouteGenericInterface { |
||||||
|
Querystring: {[key: string]: string|string[]|undefined} |
||||||
|
} |
||||||
|
|
||||||
|
export function getBaseUrl(request: FastifyRequest): string { |
||||||
|
const hostHeader = request.hostname ?? "localhost" |
||||||
|
const proto = getFirstValue(request.headers["x-forwarded-proto"]) ?? "http" |
||||||
|
const path = getFirstValue(request.headers["x-base-path"]) ?? "/" |
||||||
|
return `${proto}://${hostHeader}${path}` |
||||||
|
} |
||||||
|
|
||||||
|
/** Translates a zero-to-many set of strings to one or zero strings. */ |
||||||
|
export function getFirstValue(value: string|string[]|undefined): string|undefined { |
||||||
|
return typeof value === "string" ? value : Array.isArray(value) ? value[0] : undefined |
||||||
|
} |
@ -0,0 +1,100 @@ |
|||||||
|
import {SavedWebhook} from "./SavedWebhook.js"; |
||||||
|
import fastify, {FastifyInstance} from "fastify"; |
||||||
|
import {DiscordWebhookHandler} from "./DiscordWebhookHandler.js"; |
||||||
|
import {getBaseUrl, RouteWithQuerystring} from "./FastifyHelpers.js"; |
||||||
|
import pug from "pug"; |
||||||
|
import {renderError} from "./PugRenderer.js"; |
||||||
|
|
||||||
|
export class GameServer { |
||||||
|
readonly server: FastifyInstance |
||||||
|
readonly gameWebhook: SavedWebhook |
||||||
|
readonly adminWebhook: SavedWebhook |
||||||
|
readonly appId: string |
||||||
|
readonly secret: string |
||||||
|
readonly port: number |
||||||
|
constructor({appId, secret, port}: {appId: string, secret: string, port: number}) { |
||||||
|
this.server = fastify({ |
||||||
|
logger: true |
||||||
|
}) |
||||||
|
this.appId = appId |
||||||
|
this.secret = secret |
||||||
|
this.port = port |
||||||
|
this.gameWebhook = new SavedWebhook("broadcasthook.json", {logger: this.server.log}) |
||||||
|
this.adminWebhook = new SavedWebhook("logginghook.json", {logger: this.server.log}) |
||||||
|
} |
||||||
|
|
||||||
|
async initialize(): Promise<void> { |
||||||
|
const gameHandler = new DiscordWebhookHandler({ |
||||||
|
webhook: this.gameWebhook, |
||||||
|
templateFilename: "setupGame.pug", |
||||||
|
appId: this.appId, |
||||||
|
secret: this.secret, |
||||||
|
destinationFunc: () => { |
||||||
|
if (this.adminWebhook.isPresent) { |
||||||
|
return "clear" |
||||||
|
} else { |
||||||
|
return "adminChannel" |
||||||
|
} |
||||||
|
}, |
||||||
|
}) |
||||||
|
const adminHandler = new DiscordWebhookHandler({ |
||||||
|
webhook: this.adminWebhook, |
||||||
|
templateFilename: "setupAdmin.pug", |
||||||
|
appId: this.appId, |
||||||
|
secret: this.secret, |
||||||
|
destinationFunc: () => { |
||||||
|
if (this.gameWebhook.isPresent) { |
||||||
|
return "clear" |
||||||
|
} else { |
||||||
|
return "gameChannel" |
||||||
|
} |
||||||
|
}, |
||||||
|
}) |
||||||
|
this.server.get<RouteWithQuerystring>("/setup", async (req, res) => { |
||||||
|
if (!this.gameWebhook.isPresent) { |
||||||
|
res.redirect(`setup/gameChannel`) |
||||||
|
} else if (!this.adminWebhook.isPresent) { |
||||||
|
res.redirect(`setup/adminChannel`) |
||||||
|
} else { |
||||||
|
res.redirect(`setup/done`) |
||||||
|
} |
||||||
|
}) |
||||||
|
this.server.get<RouteWithQuerystring>("/setup/gameChannel", async (req, res) => { |
||||||
|
return await gameHandler.handleRequest(req, res) |
||||||
|
}) |
||||||
|
this.server.get<RouteWithQuerystring>("/setup/adminChannel", async (req, res) => { |
||||||
|
return await adminHandler.handleRequest(req, res) |
||||||
|
}) |
||||||
|
this.server.get<RouteWithQuerystring>("/setup/clear", async (req, res) => { |
||||||
|
try { |
||||||
|
await Promise.all([this.gameWebhook, this.adminWebhook] |
||||||
|
.filter((item) => item.isPresent) |
||||||
|
.map((item) => item.clearHook())) |
||||||
|
} catch (e) { |
||||||
|
return renderError({ |
||||||
|
baseUrl: getBaseUrl(req), |
||||||
|
res, |
||||||
|
error: e, |
||||||
|
context: "clearing the broadcast webhook" |
||||||
|
}) |
||||||
|
} |
||||||
|
res.redirect(".") |
||||||
|
}) |
||||||
|
this.server.get<RouteWithQuerystring>("/setup/done", async (req, res) => { |
||||||
|
if (!this.gameWebhook.isPresent) { |
||||||
|
res.redirect("gameChannel") |
||||||
|
} else if (!this.adminWebhook.isPresent) { |
||||||
|
res.redirect("adminChannel") |
||||||
|
} else { |
||||||
|
res.code(200) |
||||||
|
res.type("text/html") |
||||||
|
res.send(pug.renderFile("static/pages/setupDone.pug", { |
||||||
|
baseUrl: getBaseUrl(req), |
||||||
|
clearUrl: "setup/clear", |
||||||
|
gameSetupUrl: "setup/gameChannel", |
||||||
|
adminSetupUrl: "setup/adminChannel"})) |
||||||
|
} |
||||||
|
}) |
||||||
|
await Promise.all([this.gameWebhook.load(), this.adminWebhook.load(), this.server.listen(this.port, "127.0.0.1")]) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
export function isNodeErr(e: unknown): e is NodeJS.ErrnoException { |
||||||
|
return typeof e === "object" && e !== null && e.hasOwnProperty("code") |
||||||
|
} |
||||||
|
|
||||||
|
export function isENOENT(e: unknown): boolean { |
||||||
|
return isNodeErr(e) && e.code === "ENOENT" |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
import {FastifyReply} from "fastify"; |
||||||
|
import {renderFile} from "pug"; |
||||||
|
|
||||||
|
/** Renders the error page into the given reply. */ |
||||||
|
export function renderError({baseUrl, res, error, context}: {baseUrl: string, res: FastifyReply, error: unknown, context: string}): void { |
||||||
|
res.code(500) |
||||||
|
res.type("text/html") |
||||||
|
res.send(renderFile("static/pages/error.pug", { |
||||||
|
baseUrl, |
||||||
|
error, |
||||||
|
context, |
||||||
|
})) |
||||||
|
} |
@ -0,0 +1,143 @@ |
|||||||
|
/** */ |
||||||
|
import {APIWebhook} from "discord-api-types"; |
||||||
|
import {FastifyLoggerInstance} from "fastify"; |
||||||
|
import {readFile as fsReadFile, writeFile as fsWriteFile, mkdir as fsMkdir, rm as fsRm} from "fs/promises"; |
||||||
|
import axios from "axios"; |
||||||
|
import {join} from "path"; |
||||||
|
import {isENOENT} from "./NodeErrorHelpers.js"; |
||||||
|
|
||||||
|
const WebhookPath = "runtime/webhooks" |
||||||
|
|
||||||
|
/** File-based storage for webhook instances. */ |
||||||
|
export class SavedWebhook { |
||||||
|
readonly filename: string |
||||||
|
private _state: APIWebhook|null|undefined |
||||||
|
private readonly logger: FastifyLoggerInstance|null |
||||||
|
private readonly readFile: typeof fsReadFile |
||||||
|
private readonly writeFile: typeof fsWriteFile |
||||||
|
private readonly mkdir: typeof fsMkdir |
||||||
|
private readonly rm: typeof fsRm |
||||||
|
private readonly delRequest: typeof axios["delete"] |
||||||
|
|
||||||
|
/** Initializes a SavedWebhook pointing at the given location. */ |
||||||
|
constructor(filename: string, {logger, state, readFile, writeFile, mkdir, rm, delRequest}: {logger?: FastifyLoggerInstance|null, state?: APIWebhook|null, readFile?: typeof fsReadFile, writeFile?: typeof fsWriteFile, mkdir?: typeof fsMkdir, rm?: typeof fsRm, delRequest?: typeof axios["delete"]}) { |
||||||
|
this.filename = filename |
||||||
|
this._state = state |
||||||
|
this.logger = logger?.child({webhookFile: filename}) ?? null |
||||||
|
this.readFile = readFile ?? fsReadFile |
||||||
|
this.writeFile = writeFile ?? fsWriteFile |
||||||
|
this.mkdir = mkdir ?? fsMkdir |
||||||
|
this.rm = rm ?? fsRm |
||||||
|
this.delRequest = delRequest ?? axios.delete.bind(axios) |
||||||
|
} |
||||||
|
|
||||||
|
/** The path of the webhook, including WebhookPath. */ |
||||||
|
get path(): string { |
||||||
|
return join(WebhookPath, this.filename) |
||||||
|
} |
||||||
|
|
||||||
|
/** Gets whether a webhook has been added to this SavedWebhook. */ |
||||||
|
get isPresent(): boolean { |
||||||
|
return this.state !== null |
||||||
|
} |
||||||
|
|
||||||
|
/** Gets the current state of the webhook, either a webhook instance or null. */ |
||||||
|
get state(): APIWebhook|null { |
||||||
|
if (this._state === undefined) { |
||||||
|
this.logger?.warn("SavedWebhook was not initialized before having its state checked") |
||||||
|
return null |
||||||
|
} |
||||||
|
return this._state |
||||||
|
} |
||||||
|
|
||||||
|
/** Loads the current state from the disk, including a null state if not present. */ |
||||||
|
async load(): Promise<APIWebhook|null> { |
||||||
|
if (this._state !== undefined) { |
||||||
|
this.logger?.warn(`SavedWebhook was double-initialized`) |
||||||
|
} |
||||||
|
try { |
||||||
|
const text = await this.readFile(this.path, {encoding: "utf-8"}) |
||||||
|
const state: APIWebhook = JSON.parse(text) |
||||||
|
this._state = state |
||||||
|
return state |
||||||
|
} catch (e) { |
||||||
|
if (isENOENT(e)) { |
||||||
|
this._state = null |
||||||
|
return null |
||||||
|
} |
||||||
|
throw e |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Replaces the webhook with a new one. */ |
||||||
|
async replaceHook(newHook: APIWebhook): Promise<void> { |
||||||
|
if (this._state === undefined) { |
||||||
|
this.logger?.warn("SavedWebhook was not initialized before being replaced") |
||||||
|
return |
||||||
|
} |
||||||
|
if (this._state !== null) { |
||||||
|
await this.clearHook() |
||||||
|
} |
||||||
|
this._state = newHook |
||||||
|
await this.save() |
||||||
|
} |
||||||
|
|
||||||
|
/** Removes the webhook entirely. */ |
||||||
|
async clearHook(): Promise<void> { |
||||||
|
if (this._state === undefined) { |
||||||
|
this.logger?.warn("SavedWebhook was not initialized before being cleared") |
||||||
|
return |
||||||
|
} |
||||||
|
if (this._state === null) { |
||||||
|
this.logger?.warn("SavedWebhook was already cleared and cleared again") |
||||||
|
return |
||||||
|
} |
||||||
|
await this.deleteHook() |
||||||
|
this._state = null |
||||||
|
await this.save() |
||||||
|
} |
||||||
|
|
||||||
|
/** Saves the current state to disk, deleting the file if the state is null. */ |
||||||
|
private async save(): Promise<void> { |
||||||
|
if (this._state === undefined) { |
||||||
|
this.logger?.warn("SavedWebhook was not initialized before being saved") |
||||||
|
return |
||||||
|
} |
||||||
|
if (this._state === null) { |
||||||
|
try { |
||||||
|
await this.rm(this.path) |
||||||
|
} catch (e) { |
||||||
|
if (!isENOENT(e)) { |
||||||
|
throw e |
||||||
|
} |
||||||
|
this.logger?.warn("SavedWebhook was re-deleted despite not existing to begin with.") |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
try { |
||||||
|
const text = JSON.stringify(this._state) |
||||||
|
await this.writeFile(this.path, text, {encoding: "utf-8"}) |
||||||
|
} catch (e) { |
||||||
|
if (isENOENT(e)) { |
||||||
|
await this.mkdir(WebhookPath, {recursive: true}) |
||||||
|
const text = JSON.stringify(this._state) |
||||||
|
await this.writeFile(this.path, text, {encoding: "utf-8"}) |
||||||
|
} |
||||||
|
throw e |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Deletes the webhook from the server. */ |
||||||
|
private async deleteHook(): Promise<void> { |
||||||
|
if (this._state === undefined) { |
||||||
|
this.logger?.warn("SavedWebhook was not initialized before being deleted") |
||||||
|
return |
||||||
|
} |
||||||
|
if (this._state === null) { |
||||||
|
this.logger?.warn("SavedWebhook was deleted despite being empty") |
||||||
|
return |
||||||
|
} |
||||||
|
await this.delRequest(`https://discord.com/api/webhooks/${this._state.id}/${this._state.token}`, |
||||||
|
{validateStatus: (s) => s === 204 || s === 404 }) |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,21 @@ |
|||||||
|
import dotenv from "dotenv"; |
||||||
|
import {GameServer} from "./GameServer.js"; |
||||||
|
|
||||||
|
async function main(): Promise<void> { |
||||||
|
const {parsed, error} = dotenv.config() |
||||||
|
if (error || !parsed) { |
||||||
|
throw error ?? Error("No parsed data.") |
||||||
|
} |
||||||
|
const clientSecret = parsed["DISCORD_CLIENT_SECRET"] |
||||||
|
const appId = parsed["DISCORD_APP_ID"] |
||||||
|
const port = parseInt(parsed["HTTP_PORT"] ?? "5244") |
||||||
|
const server = new GameServer({ |
||||||
|
appId, port, secret: clientSecret |
||||||
|
}) |
||||||
|
return await server.initialize() |
||||||
|
} |
||||||
|
|
||||||
|
main().catch((err) => { |
||||||
|
console.log("Main crashed!") |
||||||
|
console.log(err) |
||||||
|
}) |
@ -0,0 +1,10 @@ |
|||||||
|
extends ../template/template |
||||||
|
|
||||||
|
block title |
||||||
|
| Setup Error |
||||||
|
block content |
||||||
|
| Something went wrong while #{context}. What did you do? |
||||||
|
br |
||||||
|
| The error was: #{error} |
||||||
|
block link |
||||||
|
a(href="setup") Return to Setup Page |
@ -0,0 +1,19 @@ |
|||||||
|
extends ../template/template |
||||||
|
|
||||||
|
block title |
||||||
|
| Admin Setup |
||||||
|
block content |
||||||
|
if isReset |
||||||
|
b You've already set the admin channel up, but you can set it up again differently! |
||||||
|
br |
||||||
|
| Don't worry, nothing is overwritten until you finish the Discord authentication in the link. |
||||||
|
br |
||||||
|
br |
||||||
|
| On the upcoming page, you'll need to choose the server you're going to control this game from (it can be, but |
||||||
|
| doesn't have to be, the same server the game itself is in), as well as the channel in that server that will be |
||||||
|
| used for logging informational messages and accepting administrative commands. This channel - or at least use of |
||||||
|
| application commands within it - must be limited to access by game admins only. |
||||||
|
br |
||||||
|
| Click the link to choose an admin channel. |
||||||
|
block link |
||||||
|
a(href=authUrl) Connect Admin Channel |
@ -0,0 +1,12 @@ |
|||||||
|
extends ../template/template |
||||||
|
|
||||||
|
block title |
||||||
|
| Setup Complete! |
||||||
|
block content |
||||||
|
| You're all done! Time to just play the game. |
||||||
|
br |
||||||
|
| If you'd like to reconfigure the game's channels, click one of the buttons below. |
||||||
|
block link |
||||||
|
a(href=gameSetupUrl) Reconfigure Game Channel |
||||||
|
a(href=adminSetupUrl) Reconfigure Admin Channel |
||||||
|
a(href=clearUrl) Clear All Channels |
@ -0,0 +1,18 @@ |
|||||||
|
extends ../template/template |
||||||
|
|
||||||
|
block title |
||||||
|
| Game Setup |
||||||
|
block content |
||||||
|
if isReset |
||||||
|
b You've already set the game channel up, but you can set it up again differently! |
||||||
|
br |
||||||
|
| Don't worry, nothing is overwritten until you finish the Discord authentication in the link. |
||||||
|
br |
||||||
|
br |
||||||
|
| It's time to get started on rolling up those cuties! On the upcoming page, you'll need to choose the server you're |
||||||
|
| going to add this game to, as well as the text channel that the game should broadcast to and limit its commands to |
||||||
|
| being used within. |
||||||
|
br |
||||||
|
| Click the link to choose a game channel. |
||||||
|
block link |
||||||
|
a(href=authUrl) Connect Game Channel |
@ -0,0 +1,20 @@ |
|||||||
|
html |
||||||
|
head |
||||||
|
title |
||||||
|
block title |
||||||
|
base(href=baseUrl) |
||||||
|
style. |
||||||
|
body { display: flex; flex-flow: column; justify-content: center; align-items: center; min-height: 100vh; font-family: sans-serif; background-color: darkblue; color: aliceblue } |
||||||
|
body * { max-width: 40em; } |
||||||
|
p.content { text-align: center; } |
||||||
|
p.link { display: flex; flex-flow: row; justify-content: center; align-items: center; } |
||||||
|
p.link a { display: inline-block; border-radius: 5px; background-color: cadetblue; color: black; border: 2px solid darkslateblue; padding: 0.5em; box-shadow: 2px 4px 0 rgba(255,255,255,0.55); } |
||||||
|
a:visited, a:active, a:link { color: inherit; text-decoration: none; } |
||||||
|
p.link a:active { transform: translate(2px, 4px); box-shadow: 0 0 transparent; } |
||||||
|
body |
||||||
|
h1 |
||||||
|
block title |
||||||
|
p.content |
||||||
|
block content |
||||||
|
p.link |
||||||
|
block link |
@ -0,0 +1,26 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"target": "es5", |
||||||
|
"lib": [ |
||||||
|
"dom", |
||||||
|
"dom.iterable", |
||||||
|
"esnext" |
||||||
|
], |
||||||
|
"allowJs": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"esModuleInterop": true, |
||||||
|
"allowSyntheticDefaultImports": true, |
||||||
|
"strict": true, |
||||||
|
"strictNullChecks": true, |
||||||
|
"forceConsistentCasingInFileNames": true, |
||||||
|
"noFallthroughCasesInSwitch": true, |
||||||
|
"module": "esnext", |
||||||
|
"moduleResolution": "node", |
||||||
|
"resolveJsonModule": true, |
||||||
|
"isolatedModules": true, |
||||||
|
"outDir": "build" |
||||||
|
}, |
||||||
|
"include": [ |
||||||
|
"src" |
||||||
|
] |
||||||
|
} |
Loading…
Reference in new issue