Refactor source paths

This commit is contained in:
linguist
2020-12-30 20:05:31 +01:00
parent dd0b3c5626
commit e38614a100
45 changed files with 32 additions and 36 deletions

270
source/app/action/index.mjs Normal file
View File

@@ -0,0 +1,270 @@
//Imports
import core from "@actions/core"
import github from "@actions/github"
import octokit from "@octokit/graphql"
import setup from "../setup.mjs"
import mocks from "../mocks.mjs"
import metrics from "../metrics.mjs"
;((async function () {
//Yaml boolean converter
const bool = (value, defaulted = false) => typeof value === "string" ? /^(?:[Tt]rue|[Oo]n|[Yy]es)$/.test(value) : defaulted
//Debug message buffer
const debugged = []
//Runner
try {
//Initialization
console.log(`GitHub metrics`)
console.log("─".repeat(64))
process.on("unhandledRejection", error => { throw error })
//Skip process if needed
if ((github.context.eventName === "push")&&(github.context.payload?.head_commit)) {
if (/\[Skip GitHub Action\]/.test(github.context.payload.head_commit.message)) {
console.log(`Skipped because [Skip GitHub Action] is in commit message`)
process.exit(0)
}
}
//Load configuration
const {conf, Plugins, Templates} = await setup({log:false})
console.log(`Configuration │ loaded`)
console.log(`Version │ ${conf.package.version}`)
//Debug mode
const debug = bool(core.getInput("debug"))
if (!debug)
console.debug = message => debugged.push(message)
console.log(`Debug mode │ ${debug}`)
const dflags = (core.getInput("debug_flags") || "").split(" ").filter(flag => flag)
console.log(`Debug flags │ ${dflags.join(" ") || "(none)"}`)
//Load svg template, style, fonts and query
const template = core.getInput("template") || "classic"
console.log(`Template to use │ ${template}`)
//Token for data gathering
const token = core.getInput("token") || ""
console.log(`Github token │ ${/^MOCKED/.test(token) ? "(MOCKED)" : token ? "provided" : "missing"}`)
if (!token)
throw new Error("You must provide a valid GitHub token to gather your metrics")
const api = {}
api.graphql = octokit.graphql.defaults({headers:{authorization: `token ${token}`}})
console.log(`Github GraphQL API │ ok`)
api.rest = github.getOctokit(token)
console.log(`Github REST API │ ok`)
//Apply mocking if needed
if (bool(core.getInput("use_mocked_data"))) {
Object.assign(api, await mocks(api))
console.log(`Mocked Github API │ ok`)
}
//Extract octokits
const {graphql, rest} = api
//SVG output
const filename = core.getInput("filename") || "github-metrics.svg"
console.log(`SVG output file │ ${filename}`)
//SVG optimization
const optimize = bool(core.getInput("optimize"), true)
conf.optimize = optimize
console.log(`SVG optimization │ ${optimize}`)
//GitHub user
let authenticated
try {
authenticated = (await rest.users.getAuthenticated()).data.login
}
catch {
authenticated = github.context.repo.owner
}
const user = core.getInput("user") || authenticated
console.log(`GitHub user │ ${user}`)
//Base elements
const base = {}
let parts = (core.getInput("base") || "").split(",").map(part => part.trim())
for (const part of conf.settings.plugins.base.parts)
base[`base.${part}`] = parts.includes(part)
console.log(`Base parts │ ${parts.join(", ") || "(none)"}`)
//Config
const config = {
"config.timezone":core.getInput("config_timezone") || ""
}
console.log(`Timezone │ ${config["config.timezone"] || "(system default)"}`)
//Additional plugins
const plugins = {
lines:{enabled:bool(core.getInput("plugin_lines"))},
traffic:{enabled:bool(core.getInput("plugin_traffic"))},
pagespeed:{enabled:bool(core.getInput("plugin_pagespeed"))},
habits:{enabled:bool(core.getInput("plugin_habits"))},
languages:{enabled:bool(core.getInput("plugin_languages"))},
followup:{enabled:bool(core.getInput("plugin_followup"))},
music:{enabled:bool(core.getInput("plugin_music"))},
posts:{enabled:bool(core.getInput("plugin_posts"))},
isocalendar:{enabled:bool(core.getInput("plugin_isocalendar"))},
gists:{enabled:bool(core.getInput("plugin_gists"))},
topics:{enabled:bool(core.getInput("plugin_topics"))},
projects:{enabled:bool(core.getInput("plugin_projects"))},
tweets:{enabled:bool(core.getInput("plugin_tweets"))},
}
let q = Object.fromEntries(Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => [key, true]))
console.log(`Plugins enabled │ ${Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key).join(", ")}`)
//Additional plugins options
//Pagespeed
if (plugins.pagespeed.enabled) {
plugins.pagespeed.token = core.getInput("plugin_pagespeed_token") || ""
q[`pagespeed.detailed`] = bool(core.getInput(`plugin_pagespeed_detailed`))
q[`pagespeed.screenshot`] = bool(core.getInput(`plugin_pagespeed_screenshot`))
console.log(`Pagespeed token │ ${/^MOCKED/.test(plugins.pagespeed.token) ? "(MOCKED)" : plugins.pagespeed.token ? "provided" : "missing"}`)
console.log(`Pagespeed detailed │ ${q["pagespeed.detailed"]}`)
console.log(`Pagespeed screenshot │ ${q["pagespeed.screenshot"]}`)
}
//Languages
if (plugins.languages.enabled) {
for (const option of ["ignored", "skipped"])
q[`languages.${option}`] = core.getInput(`plugin_languages_${option}`) || null
console.log(`Languages ignored │ ${q["languages.ignored"] || "(none)"}`)
console.log(`Languages skipped repos │ ${q["languages.skipped"] || "(none)"}`)
}
//Habits
if (plugins.habits.enabled) {
for (const option of ["from", "days"])
q[`habits.${option}`] = core.getInput(`plugin_habits_${option}`) || null
q[`habits.facts`] = bool(core.getInput(`plugin_habits_facts`))
q[`habits.charts`] = bool(core.getInput(`plugin_habits_charts`))
console.log(`Habits facts │ ${q["habits.facts"]}`)
console.log(`Habits charts │ ${q["habits.charts"]}`)
console.log(`Habits events to use │ ${q["habits.from"] || "(default)"}`)
console.log(`Habits days to keep │ ${q["habits.days"] || "(default)"}`)
}
//Music
if (plugins.music.enabled) {
plugins.music.token = core.getInput("plugin_music_token") || ""
for (const option of ["provider", "mode", "playlist", "limit"])
q[`music.${option}`] = core.getInput(`plugin_music_${option}`) || null
console.log(`Music provider │ ${q["music.provider"] || "(none)"}`)
console.log(`Music plugin mode │ ${q["music.mode"] || "(none)"}`)
console.log(`Music playlist │ ${q["music.playlist"] || "(none)"}`)
console.log(`Music tracks limit │ ${q["music.limit"] || "(default)"}`)
console.log(`Music token │ ${/^MOCKED/.test(plugins.music.token) ? "(MOCKED)" : plugins.music.token ? "provided" : "missing"}`)
}
//Posts
if (plugins.posts.enabled) {
for (const option of ["source", "limit"])
q[`posts.${option}`] = core.getInput(`plugin_posts_${option}`) || null
console.log(`Posts source │ ${q["posts.source"] || "(none)"}`)
console.log(`Posts limit │ ${q["posts.limit"] || "(default)"}`)
}
//Isocalendar
if (plugins.isocalendar.enabled) {
q["isocalendar.duration"] = core.getInput("plugin_isocalendar_duration") || "half-year"
console.log(`Isocalendar duration │ ${q["isocalendar.duration"]}`)
}
//Topics
if (plugins.topics.enabled) {
for (const option of ["mode", "sort", "limit"])
q[`topics.${option}`] = core.getInput(`plugin_topics_${option}`) || null
console.log(`Topics mode │ ${q["topics.mode"] || "(default)"}`)
console.log(`Topics sort mode │ ${q["topics.sort"] || "(default)"}`)
console.log(`Topics limit │ ${q["topics.limit"] || "(default)"}`)
}
//Projects
if (plugins.projects.enabled) {
for (const option of ["limit", "repositories"])
q[`projects.${option}`] = core.getInput(`plugin_projects_${option}`) || null
console.log(`Projects limit │ ${q["projects.limit"] || "(default)"}`)
console.log(`Projects repositories │ ${q["projects.repositories"] || "(none)"}`)
}
//Tweets
if (plugins.tweets.enabled) {
plugins.tweets.token = core.getInput("plugin_tweets_token") || null
for (const option of ["limit"])
q[`tweets.${option}`] = core.getInput(`plugin_tweets_${option}`) || null
console.log(`Twitter token │ ${/^MOCKED/.test(plugins.tweets.token) ? "(MOCKED)" : plugins.tweets.token ? "provided" : "missing"}`)
console.log(`Tweets limit │ ${q["tweets.limit"] || "(default)"}`)
}
//Repositories to use
const repositories = Number(core.getInput("repositories")) || 100
console.log(`Repositories to use │ ${repositories}`)
//Die on plugins errors
const die = bool(core.getInput("plugins_errors_fatal"))
console.log(`Plugin errors │ ${die ? "die" : "warn"}`)
//Verify svg
const verify = bool(core.getInput("verify"))
console.log(`Verify SVG │ ${verify}`)
//Build query
const query = JSON.parse(core.getInput("query") || "{}")
console.log(`Query additional params │ ${JSON.stringify(query)}`)
q = {...query, ...q, base:false, ...base, ...config, repositories, template}
//Render metrics
const rendered = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die, verify}, {Plugins, Templates})
console.log(`Render │ complete`)
//Commit to repository
const dryrun = bool(core.getInput("dryrun"))
if (dryrun)
console.log(`Dry-run │ complete`)
else {
//Repository and branch
const branch = github.context.ref.replace(/^refs[/]heads[/]/, "")
console.log(`Repository │ ${github.context.repo.owner}/${github.context.repo.repo}`)
console.log(`Branch │ ${branch}`)
//Committer token
const token = core.getInput("committer_token") || core.getInput("token") || ""
console.log(`Committer token │ ${/^MOCKED/.test(token) ? "(MOCKED)" : token ? "provided" : "missing"}`)
if (!token)
throw new Error("You must provide a valid GitHub token to commit your metrics")
const rest = github.getOctokit(token)
console.log(`Committer REST API │ ok`)
try {
console.log(`Committer │ ${(await rest.users.getAuthenticated()).data.login}`)
}
catch {
console.log(`Committer │ (github-actions)`)
}
//Retrieve previous render SHA to be able to update file content through API
let sha = null
try {
const {repository:{object:{oid}}} = await graphql(`
query Sha {
repository(owner: "${github.context.repo.owner}", name: "${github.context.repo.repo}") {
object(expression: "${branch}:${filename}") { ... on Blob { oid } }
}
}
`
)
sha = oid
} catch (error) { console.debug(error) }
console.log(`Previous render sha │ ${sha ?? "(none)"}`)
//Update file content through API
await rest.repos.createOrUpdateFileContents({
...github.context.repo, path:filename, message:`Update ${filename} - [Skip GitHub Action]`,
content:Buffer.from(rendered).toString("base64"),
...(sha ? {sha} : {})
})
console.log(`Commit to repo │ ok`)
}
//Success
console.log(`Success, thanks for using metrics !`)
process.exit(0)
}
//Errors
catch (error) {
console.error(error)
if (!bool(core.getInput("debug")))
for (const log of ["─".repeat(64), "An error occured, logging debug message :", ...debugged])
console.log(log)
core.setFailed(error.message)
process.exit(1)
}
})()).catch(error => process.exit(1))

227
source/app/metrics.mjs Normal file
View File

@@ -0,0 +1,227 @@
//Imports
import fs from "fs/promises"
import os from "os"
import paths from "path"
import util from "util"
import axios from "axios"
import url from "url"
import puppeteer from "puppeteer"
import processes from "child_process"
import ejs from "ejs"
import imgb64 from "image-to-base64"
import SVGO from "svgo"
//Setup
export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false}, {Plugins, Templates}) {
//Compute rendering
try {
//Init
console.debug(`metrics/compute/${login} > start`)
console.debug(util.inspect(q, {depth:Infinity, maxStringLength:256}))
const template = q.template || conf.settings.templates.default
const repositories = Math.max(0, Number(q.repositories)) || conf.settings.repositories || 100
const pending = []
const s = (value, end = "") => value > 1 ? {y:"ies", "":"s"}[end] : end
if ((!(template in Templates))||(!(template in conf.templates))||((conf.settings.templates.enabled.length)&&(!conf.settings.templates.enabled.includes(template))))
throw new Error("unsupported template")
const {image, style, fonts} = conf.templates[template]
const queries = conf.queries
const data = {base:{}, config:{}, errors:[], plugins:{}, computed:{}}
//Base parts
{
const defaulted = ("base" in q) ? !!q.base : true
for (const part of conf.settings.plugins.base.parts)
data.base[part] = `base.${part}` in q ? !!q[ `base.${part}`] : defaulted
}
//Placeholder
if (login === "placeholder")
placeholder({data, conf, q})
//Compute
else {
//Query data from GitHub API
console.debug(`metrics/compute/${login} > graphql query`)
Object.assign(data, await graphql(queries.common({login, "calendar.from":new Date(Date.now()-14*24*60*60*1000).toISOString(), "calendar.to":(new Date()).toISOString()})))
//Query repositories from GitHub API
{
//Iterate through repositories
let cursor = null
let pushed = 0
do {
console.debug(`metrics/compute/${login} > retrieving repositories after ${cursor}`)
const {user:{repositories:{edges, nodes}}} = await graphql(queries.repositories({login, after:cursor ? `after: "${cursor}"` : "", repositories:Math.min(repositories, 100)}))
cursor = edges?.[edges?.length-1]?.cursor
data.user.repositories.nodes.push(...nodes)
pushed = nodes.length
} while ((pushed)&&(cursor)&&(data.user.repositories.nodes.length < repositories))
//Limit repositories
console.debug(`metrics/compute/${login} > keeping only ${repositories} repositories`)
data.user.repositories.nodes.splice(repositories)
console.debug(`metrics/compute/${login} > loaded ${data.user.repositories.nodes.length} repositories`)
}
//Compute metrics
console.debug(`metrics/compute/${login} > compute`)
const computer = Templates[template].default || Templates[template]
await computer({login, q, dflags}, {conf, data, rest, graphql, plugins, queries}, {s, pending, imports:{plugins:Plugins, url, imgb64, axios, puppeteer, run, fs, os, paths, util, format, bytes, shuffle, htmlescape, urlexpand}})
const promised = await Promise.all(pending)
//Check plugins errors
{
const errors = [...promised.filter(({result = null}) => result?.error), ...data.errors]
if (errors.length) {
console.warn(`metrics/compute/${login} > ${errors.length} errors !`)
if (die)
throw new Error(`An error occured during rendering, dying`)
else
console.warn(util.inspect(errors, {depth:Infinity, maxStringLength:256}))
}
}
}
//Template rendering
console.debug(`metrics/compute/${login} > render`)
let rendered = await ejs.render(image, {...data, s, style, fonts}, {async:true})
//Optimize rendering
if ((conf.optimize)&&(!q.raw)) {
console.debug(`metrics/compute/${login} > optimize`)
const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]})
const {data:optimized} = await svgo.optimize(rendered)
rendered = optimized
}
//Verify svg
if (verify) {
console.debug(`metrics/compute/${login} > verify SVG`)
const libxmljs = (await import("libxmljs")).default
const parsed = libxmljs.parseXml(rendered)
if (parsed.errors.length)
throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`)
}
//Result
console.debug(`metrics/compute/${login} > success`)
return rendered
}
//Internal error
catch (error) {
//User not found
if (((Array.isArray(error.errors))&&(error.errors[0].type === "NOT_FOUND")))
throw new Error("user not found")
//Generic error
throw error
}
}
/** Formatter */
function format(n) {
for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}])
if (n/v >= 1)
return `${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}`
return n
}
/** Bytes formatter */
function bytes(n) {
for (const {u, v} of [{u:"E", v:10**18}, {u:"P", v:10**15}, {u:"T", v:10**12}, {u:"G", v:10**9}, {u:"M", v:10**6}, {u:"k", v:10**3}])
if (n/v >= 1)
return `${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")} ${u}B`
return `${n} byte${n > 1 ? "s" : ""}`
}
/** Array shuffler */
function shuffle(array) {
for (let i = array.length-1; i > 0; i--) {
const j = Math.floor(Math.random()*(i+1))
;[array[i], array[j]] = [array[j], array[i]]
}
return array
}
/** Escape html */
function htmlescape(string, u = {"&":true, "<":true, ">":true, '"':true, "'":true}) {
return string
.replace(/&(?!(?:amp|lt|gt|quot|apos);)/g, u["&"] ? "&amp;" : "&")
.replace(/</g, u["<"] ? "&lt;" : "<")
.replace(/>/g, u[">"] ? "&gt;" : ">")
.replace(/"/g, u['"'] ? "&quot;" : '"')
.replace(/'/g, u["'"] ? "&apos;" : "'")
}
/** Expand url */
async function urlexpand(url) {
try {
return (await axios.get(url)).request.res.responseUrl
} catch {
return url
}
}
/** Run command */
async function run(command, options) {
return await new Promise((solve, reject) => {
console.debug(`metrics/command > ${command}`)
const child = processes.exec(command, options)
let [stdout, stderr] = ["", ""]
child.stdout.on("data", data => stdout += data)
child.stderr.on("data", data => stderr += data)
child.on("close", code => {
console.debug(`metrics/command > ${command} > exited with code ${code}`)
return code === 0 ? solve(stdout) : reject(stderr)
})
})
}
/** Placeholder generator */
function placeholder({data, conf, q}) {
//Proxifier
const proxify = (target) => (typeof target === "object")&&(target) ? new Proxy(target, {
get(target, property) {
//Primitive conversion
if (property === Symbol.toPrimitive)
return () => "##"
//Iterables
if (property === Symbol.iterator)
return Reflect.get(target, property)
//Plugins should not be proxified by default as they can be toggled by user
if (/^plugins$/.test(property))
return Reflect.get(target, property)
//Consider no errors on plugins
if (/^error/.test(property))
return undefined
//Proxify recursively
return proxify(property in target ? Reflect.get(target, property) : {})
}
}) : target
//Enabled plugins
const enabled = Object.entries(conf.settings.plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key).filter(key => (key in q)&&(q[key]))
//Placeholder data
Object.assign(data, {
s(_, letter) { return letter === "y" ? "ies" : "s" },
meta:{version:conf.package.version, author:conf.package.author, placeholder:true},
user:proxify({name:`############`, websiteUrl:`########################`, isHireable:false}),
computed:proxify({
avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
registration:"## years ago",
cakeday:false,
calendar:new Array(14).fill({color:"#ebedf0"}),
licenses:{favorite:`########`},
token:{scopes:[]},
}),
plugins:Object.fromEntries(enabled.map(key =>
[key, proxify({
posts:{source:"########", list:new Array("posts.limit" in q ? Math.max(Number(q["posts.limit"])||0, 0) : 2).fill({title:"###### ###### ####### ######", date:"####"})},
music:{provider:"########", tracks:new Array("music.limit" in q ? Math.max(Number(q["music.limit"])||0, 0) : 4).fill({name:"##########", artist:"######", artwork:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg=="})},
pagespeed:{detailed:!!q["pagespeed.detailed"], screenshot:!!q["pagespeed.screenshot"] ? "data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==" : null, scores:["Performance", "Accessibility", "Best Practices", "SEO"].map(title => ({title, score:NaN}))},
followup:{issues:{count:0}, pr:{count:0}},
habits:{facts:!!(q["habits.facts"] ?? 1), charts:!!q["habits.charts"], indents:{style:`########`}, commits:{day:"####"}, linguist:{ordered:[]}},
languages:{favorites:new Array(7).fill(null).map((_, x) => ({x, name:"######", color:"#ebedf0", value:1/(x+1)}))},
topics:{mode:"topics.mode" in q ? q["topics.mode"] : "starred", list:[...new Array("topics.limit" in q ? Math.max(Number(q["topics.limit"])||0, 0) : 12).fill(null).map(() => ({name:"######", description:"", icon:null})), {name:`And ## more...`, description:"", icon:null}]},
projects:{list:[...new Array("projects.limit" in q ? Math.max(Number(q["projects.limit"])||0, 0) : 4).fill(null).map(() => ({name:"########", updated:"########", progress:{enabled:true, todo:"##", doing:"##", done:"##", total:"##"}}))]},
tweets:{profile:{username:"########", verified:false}, list:[...new Array("tweets.limit" in q ? Math.max(Number(q["tweets.limit"])||0, 0) : 2).fill(null).map(() => ({text:"###### ###### ####### ######".repeat(4), created_at:Date.now()}))]},
}[key]??{})]
)),
})
}

765
source/app/mocks.mjs Normal file
View File

@@ -0,0 +1,765 @@
//Imports
import axios from "axios"
import urls from "url"
//Mocked state
let mocked = false
//Mocking
export default async function ({graphql, rest}) {
//Check if already mocked
if (mocked)
return {graphql, rest}
mocked = true
console.debug(`metrics/compute/mocks > mocking`)
//GraphQL API mocking
{
console.debug(`metrics/compute/mocks > mocking graphql api`)
const unmocked = graphql
graphql = new Proxy(unmocked, {
apply(target, that, args) {
//Arguments
const [query] = args
//Common query
if (/^query Metrics /.test(query)) {
console.debug(`metrics/compute/mocks > mocking graphql api result > Metrics`)
return ({
user: {
databaseId:22963968,
name:"Simon Lecoq",
login:"lowlighter",
createdAt:"2016-10-20T16:49:29Z",
avatarUrl:"https://avatars0.githubusercontent.com/u/22963968?u=f5097de6f06ed2e31906f784163fc1e9fc84ed57&v=4",
websiteUrl:"https://simon.lecoq.io",
isHireable:false,
twitterUsername:"lecoqsimon",
repositories:{totalCount:Math.floor(Math.random()*100), totalDiskUsage:Math.floor(Math.random()*100000), nodes:[]},
packages:{totalCount:Math.floor(Math.random()*10)},
starredRepositories:{totalCount:Math.floor(Math.random()*1000)},
watching:{totalCount:Math.floor(Math.random()*100)},
sponsorshipsAsSponsor:{totalCount:Math.floor(Math.random()*5)},
sponsorshipsAsMaintainer:{totalCount:Math.floor(Math.random()*5)},
contributionsCollection:{
totalRepositoriesWithContributedCommits:Math.floor(Math.random()*30),
totalCommitContributions:Math.floor(Math.random()*1000),
restrictedContributionsCount:Math.floor(Math.random()*500),
totalIssueContributions:Math.floor(Math.random()*100),
totalPullRequestContributions:Math.floor(Math.random()*100),
totalPullRequestReviewContributions:Math.floor(Math.random()*100)
},
calendar:{
contributionCalendar:{
weeks:[
{
contributionDays:[
{color:"#40c463"},
{color:"#ebedf0"},
{color:"#9be9a8"},
{color:"#ebedf0"},
{color:"#ebedf0"}
]
},
{
contributionDays:[
{color:"#30a14e"},
{color:"#9be9a8"},
{color:"#40c463"},
{color:"#9be9a8"},
{color:"#ebedf0"},
{color:"#ebedf0"},
{color:"#ebedf0"}
]
},
{
contributionDays:[
{color:"#40c463"},
{color:"#216e39"},
{color:"#9be9a8"}
]
}
]
}
},
repositoriesContributedTo:{totalCount:Math.floor(Math.random()*10)},
followers:{totalCount:Math.floor(Math.random()*100)},
following:{totalCount:Math.floor(Math.random()*100)},
issueComments:{totalCount:Math.floor(Math.random()*100)},
organizations:{totalCount:Math.floor(Math.random()*5)}
}
})
}
//Repositories query
if (/^query Repositories /.test(query)) {
console.debug(`metrics/compute/mocks > mocking graphql api result > Repositories`)
return /after: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"/m.test(query) ? ({
user:{
repositories:{
edges:[],
nodes:[],
}
}
}) : ({
user:{
repositories:{
edges:[
{
cursor:"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
},
],
nodes:[
{
name:"metrics",
watchers:{totalCount:Math.floor(Math.random()*100)},
stargazers:{totalCount:Math.floor(Math.random()*1000)},
languages:{
edges:[
{size:111733, node:{color:"#f1e05a", name:"JavaScript"}
},
{size:14398, node:{color:"#563d7c", name:"CSS"}},
{size:13223, node:{color:"#e34c26", name:"HTML"}},
]
},
issues_open:{totalCount:Math.floor(Math.random()*100)},
issues_closed:{totalCount:Math.floor(Math.random()*100)},
pr_open:{totalCount:Math.floor(Math.random()*100)},
pr_merged:{totalCount:Math.floor(Math.random()*100)},
releases:{totalCount:Math.floor(Math.random()*100)},
forkCount:Math.floor(Math.random()*100),
licenseInfo:{spdxId:"MIT"}
},
]
}
}
})
}
//Single repository query
if (/^query Repository /.test(query)) {
console.debug(`metrics/compute/mocks > mocking graphql api result > Repository`)
return ({
user:{
repository:{
name:"metrics",
createdAt:new Date().toISOString(),
diskUsage:Math.floor(Math.random()*10000),
watchers:{totalCount:Math.floor(Math.random()*100)},
stargazers:{totalCount:Math.floor(Math.random()*1000)},
languages:{
edges:[
{size:111733, node:{color:"#f1e05a", name:"JavaScript"}
},
{size:14398, node:{color:"#563d7c", name:"CSS"}},
{size:13223, node:{color:"#e34c26", name:"HTML"}},
]
},
issues_open:{totalCount:Math.floor(Math.random()*100)},
issues_closed:{totalCount:Math.floor(Math.random()*100)},
pr_open:{totalCount:Math.floor(Math.random()*100)},
pr_merged:{totalCount:Math.floor(Math.random()*100)},
releases:{totalCount:Math.floor(Math.random()*100)},
forkCount:Math.floor(Math.random()*100),
licenseInfo:{spdxId:"MIT"}
},
}
})
}
//Calendar query
if (/^query Calendar /.test(query)) {
console.debug(`metrics/compute/mocks > mocking graphql api result > Calendar`)
//Generate calendar
const date = new Date(query.match(/from: "(?<date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z)"/)?.groups?.date)
const to = new Date(query.match(/to: "(?<date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z)"/)?.groups?.date)
const weeks = []
let contributionDays = []
for (; date <= to; date.setDate(date.getDate()+1)) {
//Create new week on sunday
if (date.getDay() === 0) {
weeks.push({contributionDays})
contributionDays = []
}
//Random contributions
const contributionCount = Math.min(10, Math.max(0, Math.floor(Math.random()*14-4)))
contributionDays.push({
contributionCount,
color:["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"][Math.ceil(contributionCount/10/0.25)],
date:date.toISOString().substring(0, 10)
})
}
return ({
user: {
calendar:{
contributionCalendar:{
weeks
}
}
}
})
}
//Gists query
if (/^query Gists /.test(query)) {
console.debug(`metrics/compute/mocks > mocking graphql api result > Projects`)
return ({
user:{
gists:{
totalCount:1,
nodes:[
{
stargazerCount:Math.floor(Math.random()*10),
isFork:false,
forks:{totalCount:Math.floor(Math.random()*10)},
files:[{name:"example"}],
comments:{totalCount:Math.floor(Math.random()*10)}
}
]
}
}
})
}
//Projects query
if (/^query Projects /.test(query)) {
console.debug(`metrics/compute/mocks > mocking graphql api result > Projects`)
return ({
user:{
projects:{
totalCount:1,
nodes:[
{
name:"User-owned project",
updatedAt:new Date().toISOString(),
progress:{
doneCount:Math.floor(Math.random()*10),
inProgressCount:Math.floor(Math.random()*10),
todoCount:Math.floor(Math.random()*10),
enabled:true
}
}
]
}
}
})
}
//Repository project query
if (/^query RepositoryProject /.test(query)) {
console.debug(`metrics/compute/mocks > mocking graphql api result > RepositoryProject`)
return ({
user:{
repository:{
project:{
name:"Repository project example",
updatedAt:new Date().toISOString(),
progress:{
doneCount:Math.floor(Math.random()*10),
inProgressCount:Math.floor(Math.random()*10),
todoCount:Math.floor(Math.random()*10),
enabled:true
}
}
}
}
})
}
//Unmocked call
return target(...args)
}
})
}
//Rest API mocking
{
console.debug(`metrics/compute/mocks > mocking rest api`)
const unmocked = {
request:rest.request,
rateLimit:rest.rateLimit.get,
listEventsForAuthenticatedUser:rest.activity.listEventsForAuthenticatedUser,
getViews:rest.repos.getViews,
getContributorsStats:rest.repos.getContributorsStats,
listCommits:rest.repos.listCommits,
}
//Raw request
rest.request = new Proxy(unmocked.request, {
apply:function(target, that, args) {
//Arguments
const [url] = args
//Head request
if (/^HEAD .$/.test(url)) {
console.debug(`metrics/compute/mocks > mocking rest api result > rest.request HEAD`)
return ({
status: 200,
url:"https://api.github.com/",
headers:{
server:"GitHub.com",
status:"200 OK",
"x-oauth-scopes":"repo",
},
data:undefined
})
}
//Commit content
if (/^https:..api.github.com.repos.lowlighter.metrics.commits.xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx/.test(url)) {
console.debug(`metrics/compute/mocks > mocking rest api result > rest.request ${url}`)
return ({
status: 200,
url:"https://api.github.com/repos/lowlighter/metrics/commits/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
data:{
sha:"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
commit:{
author:{
name:"lowlighter",
email:"22963968+lowlighter@users.noreply.github.com",
date:new Date().toISOString(),
},
committer:{
name:"lowlighter",
email:"22963968+lowlighter@users.noreply.github.com",
date:new Date().toISOString(),
},
},
author:{
login:"lowlighter",
id:22963968,
},
committer:{
login:"lowlighter",
id:22963968,
},
files: [
{
sha:"5ab8c4fb6a0be4c157419c3b9d7b522dca354b3f",
filename:"index.mjs",
patch:"@@ -0,0 +1,5 @@\n+//Imports\n+ import app from \"./src/app.mjs\"\n+\n+//Start app\n+ await app()\n\\ No newline at end of file"
},
]
}
})
}
return target(...args)
}
})
//Rate limit
rest.rateLimit.get = new Proxy(unmocked.rateLimit, {
apply:function(target, that, args) {
return ({
status: 200,
url:"https://api.github.com/rate_limit",
headers:{
server:"GitHub.com",
status:"200 OK",
"x-oauth-scopes":"repo",
},
data:{
resources:{
core:{limit:5000, used:0, remaining:5000, reset:0 },
search:{limit:30, used:0, remaining:30, reset:0 },
graphql:{limit:5000, used:0, remaining:5000, reset:0 },
integration_manifest:{limit:5000, used:0, remaining:5000, reset:0 },
source_import:{limit:100, used:0, remaining:100, reset:0 },
code_scanning_upload:{limit:500, used:0, remaining:500, reset:0 },
},
rate:{limit:5000, used:0, remaining:"MOCKED", reset:0}
}
})
}
})
//Events list
rest.activity.listEventsForAuthenticatedUser = new Proxy(unmocked.listEventsForAuthenticatedUser, {
apply:function(target, that, [{page, per_page}]) {
console.debug(`metrics/compute/mocks > mocking rest api result > rest.activity.listEventsForAuthenticatedUser`)
return ({
status:200,
url:`https://api.github.com/users/lowlighter/events?per_page=${per_page}&page=${page}`,
headers:{
server:"GitHub.com",
status:"200 OK",
"x-oauth-scopes":"repo",
},
data:page < 1 ? new Array(10).fill(null).map(() =>
(false ? {
id:"10000000001",
type:"IssueCommentEvent",
} : {
id:"10000000000",
type:"PushEvent",
actor:{
id:22963968,
login:"lowlighter",
},
repo: {
id:293860197,
name:"lowlighter/metrics",
},
payload: {
ref:"refs/heads/master",
commits: [
{
url:"https://api.github.com/repos/lowlighter/metrics/commits/xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
}
]
},
created_at:new Date(Date.now()-Math.floor(-Math.random()*14)*Math.floor(-Math.random()*24)*60*60*1000).toISOString()
})
) : []
})
}
})
//Repository traffic
rest.repos.getViews = new Proxy(unmocked.getViews, {
apply:function(target, that, args) {
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.getViews`)
const count = Math.floor(Math.random()*1000)*2
const uniques = Math.floor(Math.random()*count)*2
return ({
status:200,
url:"https://api.github.com/repos/lowlighter/metrics/traffic/views",
headers:{
server:"GitHub.com",
status:"200 OK",
"x-oauth-scopes":"repo",
},
data:{
count,
uniques,
views:[
{timestamp:new Date().toISOString(), count:count/2, uniques:uniques/2},
{timestamp:new Date().toISOString(), count:count/2, uniques:uniques/2},
]
}
})
}
})
//Repository contributions
rest.repos.getContributorsStats = new Proxy(unmocked.getContributorsStats, {
apply:function(target, that, args) {
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.getContributorsStats`)
return ({
status:200,
url:"https://api.github.com/repos/lowlighter/metrics/stats/contributors",
headers: {
server:"GitHub.com",
status:"200 OK",
"x-oauth-scopes":"repo",
},
data:[
{
total:Math.floor(Math.random()*1000),
weeks:[
{w:1, a:Math.floor(Math.random()*10000), d:Math.floor(Math.random()*10000), c:Math.floor(Math.random()*10000)},
{w:2, a:Math.floor(Math.random()*10000), d:Math.floor(Math.random()*10000), c:Math.floor(Math.random()*10000)},
{w:3, a:Math.floor(Math.random()*10000), d:Math.floor(Math.random()*10000), c:Math.floor(Math.random()*10000)},
{w:4, a:Math.floor(Math.random()*10000), d:Math.floor(Math.random()*10000), c:Math.floor(Math.random()*10000)},
],
author: {
login:"lowlighter",
}
}
]
})
}
})
//Repository contributions
rest.repos.listCommits = new Proxy(unmocked.listCommits, {
apply:function(target, that, [{page, per_page}]) {
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.listCommits`)
return ({
status:200,
url:`https://api.github.com/repos/lowlighter/metrics/commits?per_page=${per_page}&page=${page}`,
headers: {
server:"GitHub.com",
status:"200 OK",
"x-oauth-scopes":"repo",
},
data:page < 2 ? new Array(per_page).fill(null).map(() =>
({
sha:"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
commit:{
author:{
name:"lowlighter",
date:new Date(Date.now()-Math.floor(-Math.random()*14)*24*60*60*1000).toISOString()
},
committer:{
name:"lowlighter",
date:new Date(Date.now()-Math.floor(-Math.random()*14)*24*60*60*1000).toISOString()
},
}
})
) : []
})
}
})
}
//Axios mocking
{
console.debug(`metrics/compute/mocks > mocking axios`)
const unmocked = {
get:axios.get,
post:axios.post,
}
//Post requests
axios.post = new Proxy(unmocked.post, {
apply:function(target, that, args) {
//Arguments
const [url, body] = args
//Spotify api
if (/^https:..accounts.spotify.com.api.token/.test(url)) {
//Access token generator
const params = new urls.URLSearchParams(body)
if ((params.get("grant_type") === "refresh_token")&&(params.get("client_id") === "MOCKED_CLIENT_ID")&&(params.get("client_secret") === "MOCKED_CLIENT_SECRET")&&(params.get("refresh_token") === "MOCKED_REFRESH_TOKEN")) {
console.debug(`metrics/compute/mocks > mocking spotify api result > ${url}`)
return ({
status:200,
data:{
access_token:"MOCKED_TOKEN_ACCESS",
token_type:"Bearer",
expires_in:3600,
scope:"user-read-recently-played user-read-private",
}
})
}
}
return target(...args)
}
})
//Get requests
axios.get = new Proxy(unmocked.get, {
apply:function(target, that, args) {
//Arguments
const [url, options] = args
//Pagespeed api
if (/^https:..www.googleapis.com.pagespeedonline.v5/.test(url)) {
//Pagespeed result
if (/v5.runPagespeed.*&key=MOCKED_TOKEN/.test(url)) {
console.debug(`metrics/compute/mocks > mocking pagespeed api result > ${url}`)
return ({
status:200,
data:{
captchaResult:"CAPTCHA_NOT_NEEDED",
id:"https://simon.lecoq.io/",
lighthouseResult:{
requestedUrl:"https://simon.lecoq.io/",
finalUrl:"https://simon.lecoq.io/",
lighthouseVersion:"6.3.0",
audits:{
"final-screenshot":{
id:"final-screenshot",
title:"Final Screenshot",
score: null,
details:{
data:"data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
type:"screenshot",
timestamp:Date.now()
}
},
metrics:{
id:"metrics",
title:"Metrics",
score: null,
details:{
items:[
{
observedFirstContentfulPaint:283,
observedFirstVisualChangeTs:1789259909429,
observedFirstContentfulPaintTs:1789259857628,
firstContentfulPaint:370,
observedDomContentLoaded:251,
observedFirstMeaningfulPaint:642,
maxPotentialFID:203,
observedLoad:330,
firstMeaningfulPaint:370,
observedCumulativeLayoutShift:0.0028944855967078186,
observedSpeedIndex:711,
observedSpeedIndexTs:1789260285891,
observedTimeOriginTs:1789259574429,
observedLargestContentfulPaint:857,
cumulativeLayoutShift:0.0028944855967078186,
observedFirstPaintTs:1789259857628,
observedTraceEndTs:1789261300953,
largestContentfulPaint:1085,
observedTimeOrigin:0,
speedIndex:578,
observedTraceEnd:1727,
observedDomContentLoadedTs:1789259825567,
observedFirstPaint:283,
totalBlockingTime:133,
observedLastVisualChangeTs:1789260426429,
observedFirstVisualChange:335,
observedLargestContentfulPaintTs:1789260431554,
estimatedInputLatency:13,
observedLoadTs:1789259904916,
observedLastVisualChange:852,
firstCPUIdle:773,
interactive:953,
observedNavigationStartTs:1789259574429,
observedNavigationStart:0,
observedFirstMeaningfulPaintTs:1789260216895
},
]
},
},
},
categories:{
"best-practices":{
id:"best-practices",
title:"Best Practices",
score:Math.floor(Math.random()*100)/100,
},
seo:{
id:"seo",
title:"SEO",
score:Math.floor(Math.random()*100)/100,
},
accessibility:{
id:"accessibility",
title:"Accessibility",
score:Math.floor(Math.random()*100)/100,
},
performance: {
id:"performance",
title:"Performance",
score:Math.floor(Math.random()*100)/100,
}
},
},
analysisUTCTimestamp:new Date().toISOString()
}
})
}
}
//Spotify api
if (/^https:..api.spotify.com/.test(url)) {
//Get recently played tracks
if (/me.player.recently-played/.test(url)&&(options?.headers?.Authorization === "Bearer MOCKED_TOKEN_ACCESS")) {
console.debug(`metrics/compute/mocks > mocking spotify api result > ${url}`)
return ({
status:200,
data:{
items:[
{
track:{
album:{
album_type:"single",
artists:[
{
name:"EGOIST",
type:"artist",
}
],
images:[
{
height:640,
url:"https://i.scdn.co/image/ab67616d0000b27366371d0ad05c3f402d9cb2ae",
width:640
},
{
height:300,
url:"https://i.scdn.co/image/ab67616d00001e0266371d0ad05c3f402d9cb2ae",
width:300
},
{
height:64,
url:"https://i.scdn.co/image/ab67616d0000485166371d0ad05c3f402d9cb2ae",
width:64
}
],
name:"Fallen",
release_date:"2014-11-19",
type:"album",
},
artists:[
{
name:"EGOIST",
type:"artist",
}
],
name:"Fallen",
preview_url:"https://p.scdn.co/mp3-preview/f30eb6d1c55afa13ce754559a41ab683a1a76b02?cid=fa6ae353840041ee8af3bd1d21a66783",
type:"track",
},
played_at:new Date().toISOString(),
context:{
type:"album",
}
},
],
}
})
}
}
//Twitter api
if (/^https:..api.twitter.com/.test(url)) {
//Get user profile
if ((/users.by.username/.test(url))&&(options?.headers?.Authorization === "Bearer MOCKED_TOKEN")) {
console.debug(`metrics/compute/mocks > mocking twitter api result > ${url}`)
return ({
status:200,
data:{
data:{
profile_image_url:"https://pbs.twimg.com/profile_images/1338344493234286592/C_ujKIUa_normal.png",
name:"GitHub",
verified:true,
id:"13334762",
username:"github",
},
}
})
}
//Get recent tweets
if ((/tweets.search.recent/.test(url))&&(options?.headers?.Authorization === "Bearer MOCKED_TOKEN")) {
console.debug(`metrics/compute/mocks > mocking twitter api result > ${url}`)
return ({
status:200,
data:{
data:[
{
id:"1000000000000000001",
created_at:new Date().toISOString(),
entities:{
mentions:[
{start:22, end:33, username:"lowlighter"},
],
},
text:"Checkout metrics from @lowlighter ! #GitHub",
},
{
id:"1000000000000000000",
created_at:new Date().toISOString(),
text:"Hello world !",
}
],
includes:{
users:[
{
id:"100000000000000000",
name:"lowlighter",
username:"lowlighter",
},
]
},
meta:{
newest_id:"1000000000000000001",
oldest_id:"1000000000000000000",
result_count:2,
next_token:"xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
},
}
})
}
}
return target(...args)
}
})
}
//Return mocked elements
return {graphql, rest}
}

119
source/app/setup.mjs Normal file
View File

@@ -0,0 +1,119 @@
//Imports
import fs from "fs"
import path from "path"
import util from "util"
import url from "url"
const Templates = {}
const Plugins = {}
/** Setup */
export default async function ({log = true} = {}) {
//Paths
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../..")
const __statics = path.join(__metrics, "source/app/web/statics")
const __templates = path.join(__metrics, "source/templates")
const __plugins = path.join(__metrics, "source/plugins")
const __queries = path.join(__metrics, "source/queries")
const __package = path.join(__metrics, "package.json")
const __settings = path.join(__metrics, "settings.json")
const __modules = path.join(__metrics, "node_modules")
//Init
const logger = log ? console.debug : () => null
logger(`metrics/setup > setup`)
const conf = {
templates:{},
queries:{},
settings:{},
statics:__statics,
node_modules:__modules,
}
//Load settings
logger(`metrics/setup > load settings.json`)
if (fs.existsSync(__settings)) {
conf.settings = JSON.parse(`${await fs.promises.readFile(__settings)}`)
logger(`metrics/setup > load settings.json > success`)
}
else
logger(`metrics/setup > load settings.json > (missing)`)
if (!conf.settings.templates)
conf.settings.templates = {default:"classic", enabled:[]}
if (!conf.settings.plugins)
conf.settings.plugins = {}
conf.settings.plugins.base = {parts:["header", "activity", "community", "repositories", "metadata"]}
if (conf.settings.debug)
logger(util.inspect(conf.settings, {depth:Infinity, maxStringLength:256}))
//Load package settings
logger(`metrics/setup > load package.json`)
conf.package = JSON.parse(`${await fs.promises.readFile(__package)}`)
logger(`metrics/setup > load package.json > success`)
//Load templates
for (const name of await fs.promises.readdir(__templates)) {
//Cache templates file
if (!(await fs.promises.lstat(path.join(__templates, name))).isDirectory())
continue
logger(`metrics/setup > load template [${name}]`)
const files = ["image.svg", "style.css", "fonts.css"].map(file => path.join(__templates, (fs.existsSync(path.join(__templates, name, file)) ? name : "classic"), file))
const [image, style, fonts] = await Promise.all(files.map(async file => `${await fs.promises.readFile(file)}`))
conf.templates[name] = {image, style, fonts}
//Cache templates scripts
Templates[name] = (await import(url.pathToFileURL(path.join(__templates, name, "template.mjs")).href)).default
logger(`metrics/setup > load template [${name}] > success`)
//Debug
if (conf.settings.debug) {
Object.defineProperty(conf.templates, name, {
get() {
logger(`metrics/setup > reload template [${name}]`)
const [image, style, fonts] = files.map(file => `${fs.readFileSync(file)}`)
logger(`metrics/setup > reload template [${name}] > success`)
return {image, style, fonts}
}
})
}
}
//Load plugins
for (const name of await fs.promises.readdir(__plugins)) {
//Cache plugins scripts
logger(`metrics/setup > load plugin [${name}]`)
Plugins[name] = (await import(url.pathToFileURL(path.join(__plugins, name, "index.mjs")).href)).default
logger(`metrics/setup > load plugin [${name}] > success`)
}
//Load queries
for (const query of await fs.promises.readdir(__queries)) {
//Cache queries
const name = query.replace(/[.]graphql$/, "")
logger(`metrics/setup > load query [${name}]`)
conf.queries[`_${name}`] = `${await fs.promises.readFile(path.join(__queries, query))}`
logger(`metrics/setup > load query [${name}] > success`)
//Debug
if (conf.settings.debug) {
Object.defineProperty(conf.queries, `_${name}`, {
get() {
logger(`metrics/setup > reload query [${name}]`)
const raw = `${fs.readFileSync(path.join(__queries, query))}`
logger(`metrics/setup > reload query [${name}] > success`)
return raw
}
})
}
}
//Create queries formatters
Object.keys(conf.queries).map(name => conf.queries[name.substring(1)] = (vars = {}) => {
let query = conf.queries[name]
for (const [key, value] of Object.entries(vars))
query = query.replace(new RegExp(`[$]${key}`, "g"), value)
return query
})
//Conf
logger(`metrics/setup > setup > success`)
return {Templates, Plugins, conf}
}

5
source/app/web/index.mjs Normal file
View File

@@ -0,0 +1,5 @@
//Imports
import app from "./instance.mjs"
//Start app
await app({mock:process.env.USE_MOCKED_DATA})

189
source/app/web/instance.mjs Normal file
View File

@@ -0,0 +1,189 @@
//Imports
import octokit from "@octokit/graphql"
import OctokitRest from "@octokit/rest"
import express from "express"
import ratelimit from "express-rate-limit"
import compression from "compression"
import cache from "memory-cache"
import util from "util"
import setup from "../setup.mjs"
import mocks from "../mocks.mjs"
import metrics from "../metrics.mjs"
/** App */
export default async function ({mock = false} = {}) {
//Load configuration settings
const {conf, Plugins, Templates} = await setup()
const {token, maxusers = 0, restricted = [], debug = false, cached = 30*60*1000, port = 3000, ratelimiter = null, plugins = null} = conf.settings
//Apply configuration mocking if needed
if (mock) {
console.debug(`metrics/app > using mocked settings`)
const {settings} = conf
//Mock token if it's undefined
if (!settings.token)
settings.token = (console.debug(`metrics/app > using mocked token`), "MOCKED_TOKEN")
//Mock plugins state and tokens if they're undefined
for (const plugin of Object.keys(Plugins)) {
if (!settings.plugins[plugin])
settings.plugins[plugin] = {}
settings.plugins[plugin].enabled = settings.plugins[plugin].enabled ?? (console.debug(`metrics/app > using mocked token enable state for ${plugin}`), true)
if (["tweets", "pagespeed"].includes(plugin))
settings.plugins[plugin].token = settings.plugins[plugin].token ?? (console.debug(`metrics/app > using mocked token for ${plugin}`), "MOCKED_TOKEN")
if (["music"].includes(plugin))
settings.plugins[plugin].token = settings.plugins[plugin].token ?? (console.debug(`metrics/app > using mocked token for ${plugin}`), "MOCKED_CLIENT_ID, MOCKED_CLIENT_SECRET, MOCKED_REFRESH_TOKEN")
}
console.debug(util.inspect(settings, {depth:Infinity, maxStringLength:256}))
}
//Load octokits
const api = {graphql:octokit.graphql.defaults({headers:{authorization: `token ${token}`}}), rest:new OctokitRest.Octokit({auth:token})}
//Apply mocking if needed
if (mock)
Object.assign(api, await mocks(api))
const {graphql, rest} = api
//Setup server
const app = express()
app.use(compression())
const middlewares = []
//Rate limiter middleware
if (ratelimiter) {
app.set("trust proxy", 1)
middlewares.push(ratelimit({
skip(req, res) { return !!cache.get(req.params.login) },
message:"Too many requests",
...ratelimiter
}))
}
//Cache headers middleware
middlewares.push((req, res, next) => {
if (!["/placeholder"].includes(req.path))
res.header("Cache-Control", cached ? `public, max-age=${cached}` : "no-store, no-cache")
next()
})
//Base routes
const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000})
const templates = [...new Set([conf.settings.templates.default, ...(conf.settings.templates.enabled.length ? Object.keys(Templates).filter(key => conf.settings.templates.enabled.includes(key)) : Object.keys(Templates))])]
const enabled = Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key)
const actions = {flush:new Map()}
app.get("/", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`))
app.get("/index.html", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`))
app.get("/favicon.ico", limiter, (req, res) => res.sendStatus(204))
app.get("/.version", limiter, (req, res) => res.status(200).send(conf.package.version))
app.get("/.requests", limiter, async (req, res) => res.status(200).json((await rest.rateLimit.get()).data.rate))
app.get("/.templates", limiter, (req, res) => res.status(200).json(templates))
app.get("/.plugins", limiter, (req, res) => res.status(200).json(enabled))
app.get("/.plugins.base", limiter, (req, res) => res.status(200).json(conf.settings.plugins.base.parts))
app.get("/.css/style.css", limiter, (req, res) => res.sendFile(`${conf.statics}/style.css`))
app.get("/.css/style.prism.css", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/themes/prism-tomorrow.css`))
app.get("/.js/app.js", limiter, (req, res) => res.sendFile(`${conf.statics}/app.js`))
app.get("/.js/ejs.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/ejs/ejs.min.js`))
app.get("/.js/axios.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/axios/dist/axios.min.js`))
app.get("/.js/axios.min.map", limiter, (req, res) => res.sendFile(`${conf.node_modules}/axios/dist/axios.min.map`))
app.get("/.js/vue.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/vue/dist/vue.min.js`))
app.get("/.js/vue.prism.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/vue-prism-component/dist/vue-prism-component.min.js`))
app.get("/.js/vue-prism-component.min.js.map", limiter, (req, res) => res.sendFile(`${conf.node_modules}/vue-prism-component/dist/vue-prism-component.min.js.map`))
app.get("/.js/prism.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/prism.js`))
app.get("/.js/prism.yaml.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/components/prism-yaml.min.js`))
app.get("/.js/prism.markdown.min.js", limiter, (req, res) => res.sendFile(`${conf.node_modules}/prismjs/components/prism-markdown.min.js`))
app.get("/.uncache", limiter, async (req, res) => {
const {token, user} = req.query
if (token) {
if (actions.flush.has(token)) {
console.debug(`metrics/app/${actions.flush.get(token)} > flushed cache`)
cache.del(actions.flush.get(token))
return res.sendStatus(200)
}
else
return res.sendStatus(404)
}
else {
const token = `${Math.random().toString(16).replace("0.", "")}${Math.random().toString(16).replace("0.", "")}`
actions.flush.set(token, user)
return res.json({token})
}
})
//Metrics
app.get("/:login", ...middlewares, async (req, res) => {
//Request params
const {login} = req.params
if ((restricted.length)&&(!restricted.includes(login))) {
console.debug(`metrics/app/${login} > 403 (not in whitelisted users)`)
return res.sendStatus(403)
}
//Read cached data if possible
if ((!debug)&&(cached)&&(cache.get(login))) {
res.header("Content-Type", "image/svg+xml")
res.send(cache.get(login))
return
}
//Maximum simultaneous users
if ((maxusers)&&(cache.size()+1 > maxusers)) {
console.debug(`metrics/app/${login} > 503 (maximum users reached)`)
return res.sendStatus(503)
}
//Compute rendering
try {
//Render
console.debug(`metrics/app/${login} > ${util.inspect(req.query, {depth:Infinity, maxStringLength:256})}`)
const q = parse(req.query)
const rendered = await metrics({login, q}, {graphql, rest, plugins, conf, die:q["plugins.errors.fatal"] ?? false, verify:q["verify"] ?? false}, {Plugins, Templates})
//Cache
if ((!debug)&&(cached)&&(login !== "placeholder"))
cache.put(login, rendered, cached)
//Send response
res.header("Content-Type", "image/svg+xml")
res.send(rendered)
}
//Internal error
catch (error) {
//Not found user
if ((error instanceof Error)&&(/^user not found$/.test(error.message))) {
console.debug(`metrics/app/${login} > 404 (user not found)`)
return res.sendStatus(404)
}
//Invalid template
if ((error instanceof Error)&&(/^unsupported template$/.test(error.message))) {
console.debug(`metrics/app/${login} > 400 (bad request)`)
return res.sendStatus(400)
}
//General error
console.error(error)
res.sendStatus(500)
}
})
//Listen
app.listen(port, () => console.log([
`Listening on port │ ${port}`,
`Debug mode │ ${debug}`,
`Restricted to users │ ${restricted.size ? [...restricted].join(", ") : "(unrestricted)"}`,
`Cached time │ ${cached} seconds`,
`Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth:Infinity, maxStringLength:256}) : "(enabled)"}`,
`Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`,
`Plugins enabled │ ${enabled.join(", ")}`,
`Server ready !`
].join("\n")))
}
/** Query parser */
function parse(query) {
for (const [key, value] of Object.entries(query)) {
//Parse number
if (/^\d+$/.test(value))
query[key] = Number(value)
//Parse boolean
if (/^(?:true|false)$/.test(value))
query[key] = value === "true"
//Parse null
if (/^null$/.test(value))
query[key] = null
}
return query
}

View File

@@ -0,0 +1,197 @@
;(async function() {
//Init
const url = new URLSearchParams(window.location.search)
const {data:templates} = await axios.get("/.templates")
const {data:plugins} = await axios.get("/.plugins")
const {data:base} = await axios.get("/.plugins.base")
const {data:version} = await axios.get("/.version")
//App
return new Vue({
//Initialization
el:"main",
async mounted() {
//Load instance
await this.load()
//Interpolate config from browser
try {
this.config.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
} catch (error) {}
},
components:{Prism:PrismComponent},
//Data initialization
data:{
version,
user:url.get("user") || "",
palette:url.get("palette") || (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") || "light",
requests:{limit:0, used:0, remaining:0, reset:0},
config:{
timezone:"",
},
plugins:{
base,
list:plugins,
enabled:{base:Object.fromEntries(base.map(key => [key, true]))},
descriptions:{
pagespeed:"Website performances",
languages:"Most used languages",
followup:"Issues and pull requests",
traffic:"Pages views",
lines:"Lines of code changed",
habits:"Coding habits",
music:"Music plugin",
posts:"Recent posts",
isocalendar:"Isometric commit calendar",
gists:"Gists metrics",
topics:"Starred topics",
projects:"Projects",
tweets:"Latest tweets",
"base.header":"Header",
"base.activity":"Account activity",
"base.community":"Community stats",
"base.repositories":"Repositories metrics",
"base.metadata":"Metadata",
},
options:{
"languages.ignored":"",
"languages.skipped":"",
"pagespeed.detailed":false,
"pagespeed.screenshot":false,
"habits.from":200,
"habits.days":14,
"habits.facts":true,
"habits.charts":false,
"music.playlist":"",
"music.limit":4,
"posts.limit":4,
"posts.source":"dev.to",
"isocalendar.duration":"half-year",
"projects.limit":4,
"projects.repositories":"",
"topics.mode":"starred",
"topics.sort":"stars",
"topics.limit":12,
"tweets.limit":2,
},
},
templates:{
list:templates,
selected:url.get("template") || templates[0],
loaded:{},
placeholder:"",
descriptions:{
classic:"Classic template",
terminal:"Terminal template",
repository:"(hidden)",
},
},
generated:{
pending:false,
content:"",
error:false,
},
},
//Computed data
computed:{
//User's repository
repo() {
return `https://github.com/${this.user}/${this.user}`
},
//Endpoint to use for computed metrics
url() {
//Plugins enabled
const plugins = Object.entries(this.plugins.enabled)
.flatMap(([key, value]) => key === "base" ? Object.entries(value).map(([key, value]) => [`base.${key}`, value]) : [[key, value]])
.filter(([key, value]) => /^base[.]\w+$/.test(key) ? !value : value)
.map(([key, value]) => `${key}=${+value}`)
//Plugins options
const options = Object.entries(this.plugins.options)
.filter(([key, value]) => `${value}`.length)
.filter(([key, value]) => this.plugins.enabled[key.split(".")[0]])
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
//Config
const config = Object.entries(this.config).filter(([key, value]) => value).map(([key, value]) => `config.${key}=${encodeURIComponent(value)}`)
//Template
const template = (this.templates.selected !== templates[0]) ? [`template=${this.templates.selected}`] : []
//Generated url
const params = [...template, ...plugins, ...options, ...config].join("&")
return `${window.location.protocol}//${window.location.host}/${this.user}${params.length ? `?${params}` : ""}`
},
//Embedded generated code
embed() {
return `![GitHub metrics](${this.url})`
},
//GitHub action auto-generated code
action() {
return [
`# Visit https://github.com/lowlighter/metrics/blob/master/action.yml for full reference`,
`name: GitHub metrics`,
`on:`,
` # Schedule updates`,
` schedule: [{cron: "0 * * * *"}]`,
` push: {branches: "master"}`,
`jobs:`,
` github-metrics:`,
` runs-on: ubuntu-latest`,
` steps:`,
` - uses: lowlighter/metrics@latest`,
` with:`,
` # You'll need to setup a personal token in your secrets.`,
` token: ${"$"}{{ secrets.METRICS_TOKEN }}`,
` # GITHUB_TOKEN is a special auto-generated token used for commits`,
` committer_token: ${"$"}{{ secrets.GITHUB_TOKEN }}`,
``,
` # Options`,
` user: ${this.user }`,
` template: ${this.templates.selected}`,
` base: ${Object.entries(this.plugins.enabled.base).filter(([key, value]) => value).map(([key]) => key).join(", ")||'""'}`,
...[
...Object.entries(this.plugins.enabled).filter(([key, value]) => (key !== "base")&&(value)).map(([key]) => ` plugin_${key}: yes`),
...Object.entries(this.plugins.options).filter(([key, value]) => value).filter(([key, value]) => this.plugins.enabled[key.split(".")[0]]).map(([key, value]) => ` plugin_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`),
...Object.entries(this.config).filter(([key, value]) => value).map(([key, value]) => ` config_${key.replace(/[.]/, "_")}: ${typeof value === "boolean" ? {true:"yes", false:"no"}[value] : value}`),
].sort(),
].join("\n")
}
},
//Methods
methods:{
//Load and render image
async load() {
//Render placeholder
const url = this.url.replace(new RegExp(`${this.user}(\\?|$)`), "placeholder$1")
this.templates.placeholder = this.serialize((await axios.get(url)).data)
this.generated.content = ""
//Start GitHub rate limiter tracker
this.ghlimit()
},
//Generate metrics and flush cache
async generate() {
//Avoid requests spamming
if (this.generated.pending)
return
this.generated.pending = true
//Compute metrics
try {
await axios.get(`/.uncache?&token=${(await axios.get(`/.uncache?user=${this.user}`)).data.token}`)
this.generated.content = this.serialize((await axios.get(this.url)).data)
} catch {
this.generated.error = true
}
finally {
this.generated.pending = false
}
this.ghlimit({once:true})
},
//Serialize svg
serialize(svg) {
return `data:image/svg+xml;base64,${btoa(unescape(encodeURIComponent(svg)))}`
},
//Update reate limit requests
async ghlimit({once = false} = {}) {
const {data:requests} = await axios.get("/.requests")
this.requests = requests
if (!once)
setTimeout(() => this.ghlimit(), 30*1000)
}
},
})
})()

View File

@@ -0,0 +1,246 @@
<html>
<head>
<meta charset="utf-8">
<title>📊 GitHub metrics</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="A SVG image generator which includes activity, community and repositories metrics about your GitHub account that you can includes on your profile">
<meta name="author" content="lowlighter">
<link rel="icon" href="data:,">
<link rel="stylesheet" href="/.css/style.css">
<link rel="stylesheet" href="/.css/style.prism.css" />
</head>
<body>
<!-- Vue app -->
<main :class="[palette]">
<!-- Title -->
<template>
<h1><a href="https://github.com/lowlighter/metrics">Metrics v{{ version }}</a></h1>
</template>
<!-- Content -->
<template>
<section class="generator">
<!-- Steps panel -->
<section class="steps">
<div class="step">
<h2>1. Enter your GitHub username</h2>
<input type="text" name="user" v-model="user" maxlength="39" placeholder="GitHub username" :disabled="generated.pending">
</div>
<div class="step">
<h2>2. Select a template</h2>
<div class="templates">
<label v-for="template in templates.list" :key="template" v-show="templates.descriptions[template] !== '(hidden)'">
<input type="radio" v-model="templates.selected" :value="template" @change="load" :disabled="generated.pending">
{{ templates.descriptions[template] || template }}
</label>
</div>
<template v-if="plugins.base.length">
<h3>2.1 Configure base content</h3>
<div class="plugins">
<label v-for="part in plugins.base" :key="part">
<input type="checkbox" v-model="plugins.enabled.base[part]" @change="load" :disabled="generated.pending">
{{ plugins.descriptions[`base.${part}`] || `base.${part}` }}
</label>
</div>
</template>
<template v-if="plugins.list.length">
<h3>2.2 Enable additional plugins</h3>
<div class="plugins">
<label v-for="plugin in plugins.list" :key="plugin">
<input type="checkbox" v-model="plugins.enabled[plugin]" @change="load" :disabled="generated.pending">
{{ plugins.descriptions[plugin] || plugin }}
</label>
</div>
<i>*Additional plugins may be available when used as GitHub Action</i>
<template v-if="(plugins.enabled.tweets)||(plugins.enabled.music)||(plugins.enabled.pagespeed)||(plugins.enabled.languages)||(plugins.enabled.habits)||(plugins.enabled.posts)||(plugins.enabled.isocalendar)||(plugins.enabled.projects)||(plugins.enabled.topics)">
<h3>2.3 Configure additional plugins</h3>
<div class="options">
<div class="options-group" v-if="plugins.enabled.tweets">
<h4>{{ plugins.descriptions.tweets }}</h4>
<label>
Number of tweets to display
<input type="number" v-model="plugins.options['tweets.limit']" min="1" max="10" @change="load">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.music">
<h4>{{ plugins.descriptions.music }}</h4>
<label>
Playlist embed link
<input type="text" v-model="plugins.options['music.playlist']" placeholder="https://embed.music.apple.com/en/playlist/">
</label>
<label>
Number of tracks to display
<input type="number" v-model="plugins.options['music.limit']" min="1" @change="load">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.pagespeed">
<h4>{{ plugins.descriptions.pagespeed }}</h4>
<label>
Detailed PageSpeed report
<input type="checkbox" v-model="plugins.options['pagespeed.detailed']" @change="load">
</label>
<label>
Include a website screenshot
<input type="checkbox" v-model="plugins.options['pagespeed.screenshot']" @change="load">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.languages">
<h4>{{ plugins.descriptions.languages }}</h4>
<label>
Ignored languages (comma separated)
<input type="text" v-model="plugins.options['languages.ignored']" @change="load">
</label>
<label>
Skipped repositories (comma separated)
<input type="text" v-model="plugins.options['languages.skipped']" @change="load">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.habits">
<h4>{{ plugins.descriptions.habits }}</h4>
<label>
Number of events for habits
<input type="number" v-model="plugins.options['habits.from']" min="1" max="1000">
</label>
<label>
Number of days for habits
<input type="number" v-model="plugins.options['habits.days']" min="1" max="30">
</label>
<label>
Display tidbits
<input type="checkbox" v-model="plugins.options['habits.facts']" @change="load">
</label>
<label>
Display activity charts
<input type="checkbox" v-model="plugins.options['habits.charts']" @change="load">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.posts">
<h4>{{ plugins.descriptions.posts }}</h4>
<label>
Posts source
<select v-model="plugins.options['posts.source']" disabled>
<option value="dev.to">dev.to</option>
</select>
</label>
<label>
Number of posts to display
<input type="number" v-model="plugins.options['posts.limit']" min="1" @change="load">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.isocalendar">
<h4>{{ plugins.descriptions.isocalendar }}</h4>
<label>
Isocalendar duration
<select v-model="plugins.options['isocalendar.duration']">
<option value="half-year">Half year</option>
<option value="full-year">Full year</option>
</select>
</label>
</div>
<div class="options-group" v-if="plugins.enabled.topics">
<h4>{{ plugins.descriptions.topics }}</h4>
<label>
Topics display mode
<select v-model="plugins.options['topics.mode']" @change="load">
<option value="starred">Starred topics</option>
<option value="mastered">Known and mastered technologies</option>
</select>
</label>
<label>
Topics sorting
<select v-model="plugins.options['topics.sort']">
<option value="starred">Recently starred by you</option>
<option value="stars">Most stars</option>
<option value="activity">Recent actity</option>
<option value="random">Random</option>
</select>
</label>
<label>
Number of topics to display
<input type="number" v-model="plugins.options['topics.limit']" @change="load">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.projects">
<h4>{{ plugins.descriptions.projects }}</h4>
<label>
Number of projects to display
<input type="number" v-model="plugins.options['projects.limit']" min="1" max="100" @change="load">
</label>
<label>
Repositories projects to display (comma separated)
<input type="text" v-model="plugins.options['projects.repositories']" @change="load">
</label>
</div>
</div>
</template>
</template>
</div>
<div class="step">
<h2>3. Generate your metrics</h2>
<template v-if="!user">
Set your username to generate your metrics 🦑
</template>
<div class="preview-inliner">
<template v-if="generated.content">
<img class="metrics preview-inline" :src="generated.content" alt="metrics">
</template>
<template v-else>
<img class="metrics preview-inline" :src="templates.placeholder" alt="metrics">
</template>
<div class="error" v-if="generated.error">An error occurred. Please try again later.</div>
</div>
<template v-if="user">
<button @click="generate" :disabled="generated.pending">{{ generated.pending ? "Working on it :)" : "Generate your metrics !" }}</button>
</template>
<div class="palette">
Generated metrics use transparency and colors which can be read on both light and dark modes, so everyone can see your stats whatever their preferred color scheme !
<div class="palettes">
<label>
<input type="radio" v-model="palette" value="light"> ☀️ Light mode
</label>
<label>
<input type="radio" v-model="palette" value="dark"> 🌙 Night mode
</label>
</div>
</div>
</div>
<div class="step">
<h2>4. Embed these metrics on your GitHub profile</h2>
For even more features, be sure to checkout <a href="https://github.com/lowlighter/metrics">lowlighter/metrics</a> !
<template v-if="user">
<h3>4.1 Using <a href="#">{{ window.location.host }}</a></h3>
Add the markdown below in your <i>README.md</i> at <a :href="repo">{{ user }}/{{ user }}</a>
<div class="code"><Prism language="markdown" :code="embed"></Prism></div>
<h3>4. Using <a href="https://github.com/marketplace/actions/github-metrics-as-svg-image">GitHub action</a></h3>
Create a new workflow with the following content at <a :href="repo">{{ user }}/{{ user }}</a>
<div class="code"><Prism language="yaml" :code="action"></Prism></div>
</template>
</div>
</section>
<!-- Metrics preview -->
<section class="preview">
<template v-if="generated.content">
<img class="metrics" :src="generated.content" alt="metrics">
</template>
<template v-else>
<img class="metrics" :src="templates.placeholder" alt="metrics">
</template>
<div class="error" v-if="generated.error">An error occurred. Please try again later.</div>
</section>
</section>
</template>
<!-- GitHub requests tracker -->
<template>
<div class="gh-requests">{{ requests.remaining }} GitHub request{{ requests.remaining > 1 ? "s" : "" }} remaining</div>
</template>
</main>
<!-- Scripts -->
<script src="/.js/axios.min.js"></script>
<script src="/.js/prism.min.js"></script>
<script src="/.js/prism.markdown.min.js"></script>
<script src="/.js/prism.yaml.min.js"></script>
<script src="/.js/ejs.min.js"></script>
<script src="/.js/vue.min.js"></script>
<script src="/.js/vue.prism.min.js"></script>
<script src="/.js/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,213 @@
/* General */
body {
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
padding: 0;
margin: 0;
display: flex;
justify-content: center;
}
main {
background-color: #FFFFFF;
color: #1B1F23;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
padding: 1rem 1.5rem;
overflow-x: hidden;
transition: background-color .3s;
}
/* Headlines */
h1 {
font-size: 1.6rem;
margin: 1rem 0;
}
h2 {
margin: 1.5rem 0 1rem;
font-size: 1.3rem;
}
h3 {
margin: .5rem 0 .25rem;
font-size: 1.1rem;
}
/* Links */
a, a:hover, a:visited {
color: #0366D6;
text-decoration: none;
font-style: normal;
outline: none;
}
a:hover {
color: #79B8FF;
transition: color .4s;
cursor: pointer;
}
/* Inputs */
input, button, select {
border-radius: .5rem;
padding: .25rem .5rem;
outline: none;
border: 1px solid #E1E4E8;
background-color: #FAFBFC;
color: #1B1F23;
text-align: center;
cursor: pointer;
}
input:focus {
outline: none;
}
input[name=user] {
font-size: 1.1rem;
}
input[type=text], select, button {
min-width: 50%;
}
option {
text-align: center;
}
label, button {
margin: 1rem;
}
label {
padding-right: .25rem;
padding-bottom: .125rem;
}
input[disabled], button[disabled], select[disabled] {
opacity: .5;
cursor: not-allowed;
}
label:hover {
border-radius: .25rem;
background-color: #79B8FF50;
transition: background-color .4s;
cursor: pointer;
}
/* Generator */
.generator {
display: flex;
flex-grow: 1;
width: 100%;
height: 100%;
}
.generator .step {
margin-bottom: 1rem;
text-align: center;
width: 100%;
max-width: 800px;
}
.generator .steps {
flex-grow: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
.generator .preview {
display: none;
flex-shrink: 0;
}
.generator .preview .metrics {
width: 480px;
}
.generator .preview-inliner {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.generator .preview-inliner .metrics {
width: 100%;
max-width: 480px;
}
@media only screen and (min-width: 1180px) {
.generator .preview-inliner {
display: none;
}
.generator .preview {
display: block;
}
}
/* Plugins */
.plugins, .palettes {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
}
.plugins label, .palettes label {
margin: 0 1rem;
}
.options {
display: flex;
flex-direction: column;
}
.options-group {
display: flex;
flex-direction: column;
}
.options-group label {
margin: 0;
}
.options-group h4 {
margin-bottom: 0;
}
/* Code snippets */
.code {
display: flex;
justify-content: center;
align-items: center;
margin: 0 .5rem;
}
.code pre {
width: 100%;
border-radius: .5rem;
}
.code .language-markdown {
word-break: break-all !important;
white-space: pre-wrap !important;
}
details {
width: 100%;
}
details summary {
cursor: pointer;
outline: none;
}
/* Color palette */
.palette {
margin-top: 1rem;
}
main.dark {
background-color: #181A1B;
color: #D4D1C5;
}
.dark a, .dark a:visited {
color: #4CACEE;
}
.dark input, .dark button {
color: #D4D1C5;
background-color: #1A1C1E;
border-color: #373C3E;
}
.dark .code {
background-color: #1A1C1E;
}
/* Error */
.error {
color: #721c24;
background-color: #f8d7da;
padding: .75rem 1.25rem;
border: 1px solid #f5c6cb;
border-radius: .25rem;
display: flex;
justify-content: center;
align-items: center;
}
/* Github requests */
.gh-requests {
position: fixed;
right: .25rem;
bottom: .25rem;
font-size: .8rem;
}