Fix svg height, add conversion options and options to use differents data from GitHub account (#35)

* Display all features on web instance but disable them when they're not enabled

* Resize dynamically SVG output and add support to convert images to jpeg/png

* Disable animations when computing height

* Add option to disable animations

* Add options to specify different data from used GitHub account

* Update tests
This commit is contained in:
Simon Lecoq
2021-01-02 01:31:04 +01:00
committed by GitHub
parent 93839c6285
commit b649d4811e
19 changed files with 266 additions and 129 deletions

View File

@@ -38,6 +38,20 @@ inputs:
description: Timezone used by metrics description: Timezone used by metrics
default: "" default: ""
# Specify output type
# Supported values are :
# - svg (support animations and transparency)
# - png (support transparency)
# - jpeg
config_output:
description: Output image type
default: svg
# Enable or disable SVG animations
config_animations:
description: Enable or disable SVG animations
default: yes
# Number of repositories to use for metrics # Number of repositories to use for metrics
# A high number increase metrics accuracy, but will consume additional API requests when using plugins # A high number increase metrics accuracy, but will consume additional API requests when using plugins
repositories: repositories:
@@ -71,11 +85,17 @@ inputs:
default: "header, activity, community, repositories, metadata" default: "header, activity, community, repositories, metadata"
# Google PageSpeed plugin # Google PageSpeed plugin
# Enable it to compute the performance for the website attached to "user" # Enable it to compute the performance of provided website
plugin_pagespeed: plugin_pagespeed:
description: Enable Google PageSpeed metrics for user's website description: Enable Google PageSpeed metrics for user's website
default: no default: no
# Website to audit with PageSpeed
# Leave empty to default to the website attached to "user"'s GitHub account
plugin_pagespeed_url:
description: Website to audit with PageSpeed
default: ""
# Display additional PageSpeed audit metrics # Display additional PageSpeed audit metrics
# The following are displayed : # The following are displayed :
# First Contentful Paint, Speed Index, Largest Contentful Paint, Time to Interactive, Total Blocking Time, Cumulative Layout Shift # First Contentful Paint, Speed Index, Largest Contentful Paint, Time to Interactive, Total Blocking Time, Cumulative Layout Shift
@@ -217,6 +237,12 @@ inputs:
description: Posts external source description: Posts external source
default: "" default: ""
# Posts source username
# Leave empty to default to the login "user"'s GitHub account
plugin_posts_user:
description: Posts external source username
default: ""
# Number of posts to display # Number of posts to display
plugin_posts_limit: plugin_posts_limit:
description: Number of posts to display description: Number of posts to display
@@ -297,6 +323,12 @@ inputs:
description: Display recent tweets description: Display recent tweets
default: no default: no
# Twitter username
# Leave empty to default to the twitter account attached to "user"'s GitHub account
plugin_tweets_user:
description: Twitter username
default: ""
# Tweets API token (required when tweets plugin is enabled) # Tweets API token (required when tweets plugin is enabled)
# See https://apps.twitter.com for more informations # See https://apps.twitter.com for more informations
plugin_tweets_token: plugin_tweets_token:

View File

@@ -43,7 +43,7 @@
} }
//Load configuration //Load configuration
const {conf, Plugins, Templates} = await setup({log:false}) const {conf, Plugins, Templates} = await setup({log:false, nosettings:true})
info("Setup", "complete") info("Setup", "complete")
info("Version", conf.package.version) info("Version", conf.package.version)
@@ -110,9 +110,13 @@
//Config //Config
const config = { const config = {
"config.timezone":input.string("config_timezone") "config.timezone":input.string("config_timezone"),
"config.output":input.string("config_output"),
"config.animations":input.bool("config_animations"),
} }
info("Timezone", config["config.timezone"] ?? "(system default)") info("Timezone", config["config.timezone"] ?? "(system default)")
info("Convert SVG", config["config.output"] ?? "(no)")
info("Enable SVG animations", config["config.animations"])
//Additional plugins //Additional plugins
const plugins = { const plugins = {
@@ -137,6 +141,8 @@
if (plugins.pagespeed.enabled) { if (plugins.pagespeed.enabled) {
plugins.pagespeed.token = input.string("plugin_pagespeed_token") plugins.pagespeed.token = input.string("plugin_pagespeed_token")
info("Pagespeed token", plugins.pagespeed.token, {token:true}) info("Pagespeed token", plugins.pagespeed.token, {token:true})
for (const option of ["url"])
info(`Pagespeed ${option}`, q[`pagespeed.${option}`] = input.string(`plugin_pagespeed_${option}`))
for (const option of ["detailed", "screenshot"]) for (const option of ["detailed", "screenshot"])
info(`Pagespeed ${option}`, q[`pagespeed.${option}`] = input.bool(`plugin_pagespeed_${option}`)) info(`Pagespeed ${option}`, q[`pagespeed.${option}`] = input.bool(`plugin_pagespeed_${option}`))
} }
@@ -163,7 +169,7 @@
} }
//Posts //Posts
if (plugins.posts.enabled) { if (plugins.posts.enabled) {
for (const option of ["source"]) for (const option of ["source", "user"])
info(`Posts ${option}`, q[`posts.${option}`] = input.string(`plugin_posts_${option}`)) info(`Posts ${option}`, q[`posts.${option}`] = input.string(`plugin_posts_${option}`))
for (const option of ["limit"]) for (const option of ["limit"])
info(`Posts ${option}`, q[`posts.${option}`] = input.number(`plugin_posts_${option}`)) info(`Posts ${option}`, q[`posts.${option}`] = input.number(`plugin_posts_${option}`))
@@ -191,6 +197,8 @@
if (plugins.tweets.enabled) { if (plugins.tweets.enabled) {
plugins.tweets.token = input.string("plugin_tweets_token") plugins.tweets.token = input.string("plugin_tweets_token")
info("Tweets token", plugins.tweets.token, {token:true}) info("Tweets token", plugins.tweets.token, {token:true})
for (const option of ["user"])
info(`Tweets ${option}`, q[`tweets.${option}`] = input.string(`plugin_tweets_${option}`))
for (const option of ["limit"]) for (const option of ["limit"])
info(`Tweets ${option}`, q[`tweets.${option}`] = input.number(`plugin_tweets_${option}`)) info(`Tweets ${option}`, q[`tweets.${option}`] = input.number(`plugin_tweets_${option}`))
} }
@@ -209,7 +217,7 @@
q = {...query, ...q, base:false, ...base, ...config, repositories, template} q = {...query, ...q, base:false, ...base, ...config, repositories, template}
//Render metrics //Render metrics
const rendered = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die, verify}, {Plugins, Templates}) const {rendered} = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die, verify}, {Plugins, Templates})
info("Rendering", "complete") info("Rendering", "complete")
//Commit to repository //Commit to repository

View File

@@ -12,7 +12,7 @@
import SVGO from "svgo" import SVGO from "svgo"
//Setup //Setup
export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false}, {Plugins, Templates}) { export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false, convert = null}, {Plugins, Templates}) {
//Compute rendering //Compute rendering
try { try {
@@ -27,7 +27,7 @@
throw new Error("unsupported template") throw new Error("unsupported template")
const {image, style, fonts} = conf.templates[template] const {image, style, fonts} = conf.templates[template]
const queries = conf.queries const queries = conf.queries
const data = {base:{}, config:{}, errors:[], plugins:{}, computed:{}} const data = {animated:true, base:{}, config:{}, errors:[], plugins:{}, computed:{}}
//Base parts //Base parts
{ {
@@ -83,27 +83,32 @@
//Template rendering //Template rendering
console.debug(`metrics/compute/${login} > render`) console.debug(`metrics/compute/${login} > render`)
let rendered = await ejs.render(image, {...data, s, style, fonts}, {async:true}) let rendered = await ejs.render(image, {...data, s, style, fonts}, {async:true})
//Apply resizing
const {resized, mime} = await svgresize(rendered, {convert})
rendered = resized
//Optimize rendering //Additional SVG transformations
if ((conf.optimize)&&(!q.raw)) { if (/svg/.test(mime)) {
console.debug(`metrics/compute/${login} > optimize`) //Optimize rendering
const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]}) if ((conf.optimize)&&(!q.raw)) {
const {data:optimized} = await svgo.optimize(rendered) console.debug(`metrics/compute/${login} > optimize`)
rendered = optimized const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]})
} const {data:optimized} = await svgo.optimize(rendered)
rendered = optimized
//Verify svg }
if (verify) { //Verify svg
console.debug(`metrics/compute/${login} > verify SVG`) if (verify) {
const libxmljs = (await import("libxmljs")).default console.debug(`metrics/compute/${login} > verify SVG`)
const parsed = libxmljs.parseXml(rendered) const libxmljs = (await import("libxmljs")).default
if (parsed.errors.length) const parsed = libxmljs.parseXml(rendered)
throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`) if (parsed.errors.length)
throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`)
}
} }
//Result //Result
console.debug(`metrics/compute/${login} > success`) console.debug(`metrics/compute/${login} > success`)
return rendered return {rendered, mime}
} }
//Internal error //Internal error
catch (error) { catch (error) {
@@ -174,6 +179,45 @@
}) })
} }
/** Render svg */
async function svgresize(svg, {convert} = {}) {
//Instantiate browser if needed
if (!svgresize.browser) {
svgresize.browser = await puppeteer.launch({headless:true, executablePath:process.env.PUPPETEER_BROWSER_PATH, args:["--no-sandbox", "--disable-extensions", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]})
console.debug(`metrics/svgresize > started ${await svgresize.browser.version()}`)
}
//Render through browser and resize height
const page = await svgresize.browser.newPage()
await page.setContent(svg, {waitUntil:"load"})
let mime = "image/svg+xml"
let {resized, width, height} = await page.evaluate(async () => {
//Disable animations
const animated = !document.querySelector("svg").classList.contains("no-animations")
if (animated)
document.querySelector("svg").classList.add("no-animations")
//Get bounds and resize
let {y:height, width} = document.querySelector("svg #metrics-end").getBoundingClientRect()
height = Math.ceil(height)
width = Math.ceil(width)
//Resize svg
document.querySelector("svg").setAttribute("height", height)
//Enable animations
if (animated)
document.querySelector("svg").classList.remove("no-animations")
//Result
return {resized:new XMLSerializer().serializeToString(document.querySelector("svg")), height, width}
})
//Convert if required
if (convert) {
console.debug(`metrics/svgresize > convert to ${convert}`)
resized = await page.screenshot({type:convert, clip:{x:0, y:0, width, height}, omitBackground:true})
mime = `image/${convert}`
}
//Result
await page.close()
return {resized, mime}
}
/** Placeholder generator */ /** Placeholder generator */
function placeholder({data, conf, q}) { function placeholder({data, conf, q}) {
//Proxifier //Proxifier

View File

@@ -7,7 +7,7 @@
const Plugins = {} const Plugins = {}
/** Setup */ /** Setup */
export default async function ({log = true} = {}) { export default async function ({log = true, nosettings = false} = {}) {
//Paths //Paths
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../..") const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../..")
@@ -33,8 +33,12 @@
//Load settings //Load settings
logger(`metrics/setup > load settings.json`) logger(`metrics/setup > load settings.json`)
if (fs.existsSync(__settings)) { if (fs.existsSync(__settings)) {
conf.settings = JSON.parse(`${await fs.promises.readFile(__settings)}`) if (nosettings)
logger(`metrics/setup > load settings.json > success`) logger(`metrics/setup > load settings.json > skipped because no settings is enabled`)
else {
conf.settings = JSON.parse(`${await fs.promises.readFile(__settings)}`)
logger(`metrics/setup > load settings.json > success`)
}
} }
else else
logger(`metrics/setup > load settings.json > (missing)`) logger(`metrics/setup > load settings.json > (missing)`)

View File

@@ -2,4 +2,4 @@
import app from "./instance.mjs" import app from "./instance.mjs"
//Start app //Start app
await app({mock:process.env.USE_MOCKED_DATA}) await app({mock:process.env.USE_MOCKED_DATA, nosettings:process.env.NO_SETTINGS})

View File

@@ -11,11 +11,12 @@
import metrics from "../metrics.mjs" import metrics from "../metrics.mjs"
/** App */ /** App */
export default async function ({mock = false} = {}) { export default async function ({mock = false, nosettings = false} = {}) {
//Load configuration settings //Load configuration settings
const {conf, Plugins, Templates} = await setup() const {conf, Plugins, Templates} = await setup({nosettings})
const {token, maxusers = 0, restricted = [], debug = false, cached = 30*60*1000, port = 3000, ratelimiter = null, plugins = null} = conf.settings const {token, maxusers = 0, restricted = [], debug = false, cached = 30*60*1000, port = 3000, ratelimiter = null, plugins = null} = conf.settings
cache.placeholder = new Map()
//Apply configuration mocking if needed //Apply configuration mocking if needed
if (mock) { if (mock) {
@@ -66,8 +67,8 @@
//Base routes //Base routes
const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000}) 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 templates = Object.entries(Templates).map(([name]) => ({name, enabled:(conf.settings.templates.enabled.length ? conf.settings.templates.enabled.includes(name) : true) ?? false}))
const enabled = Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key) const enabled = Object.entries(Plugins).map(([name]) => ({name, enabled:plugins[name].enabled ?? false}))
const actions = {flush:new Map()} const actions = {flush:new Map()}
app.get("/", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`)) 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("/index.html", limiter, (req, res) => res.sendFile(`${conf.statics}/index.html`))
@@ -117,11 +118,20 @@
return res.sendStatus(403) return res.sendStatus(403)
} }
//Read cached data if possible //Read cached data if possible
if ((!debug)&&(cached)&&(cache.get(login))) { //Placeholder
res.header("Content-Type", "image/svg+xml") if ((login === "placeholder")&&(cache.placeholder.has(Object.keys(req.query).sort().join("-")))) {
res.send(cache.get(login)) const {rendered, mime} = cache.placeholder.get(Object.keys(req.query).sort().join("-"))
return res.header("Content-Type", mime)
} res.send(rendered)
return
}
//User cached
if ((!debug)&&(cached)&&(cache.get(login))) {
const {rendered, mime} = cache.get(login)
res.header("Content-Type", mime)
res.send(rendered)
return
}
//Maximum simultaneous users //Maximum simultaneous users
if ((maxusers)&&(cache.size()+1 > maxusers)) { if ((maxusers)&&(cache.size()+1 > maxusers)) {
console.debug(`metrics/app/${login} > 503 (maximum users reached)`) console.debug(`metrics/app/${login} > 503 (maximum users reached)`)
@@ -133,12 +143,19 @@
//Render //Render
console.debug(`metrics/app/${login} > ${util.inspect(req.query, {depth:Infinity, maxStringLength:256})}`) console.debug(`metrics/app/${login} > ${util.inspect(req.query, {depth:Infinity, maxStringLength:256})}`)
const q = parse(req.query) 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}) const {rendered, mime} = await metrics({login, q}, {
graphql, rest, plugins, conf,
die:q["plugins.errors.fatal"] ?? false,
verify:q["verify"] ?? false,
convert:["jpeg", "png"].includes(q["config.output"]) ? q["config.output"] : null
}, {Plugins, Templates})
//Cache //Cache
if ((!debug)&&(cached)&&(login !== "placeholder")) if (login === "placeholder")
cache.put(login, rendered, cached) cache.placeholder.set(Object.keys(req.query).sort().join("-"), rendered)
if ((!debug)&&(cached))
cache.put(login, {rendered, mime}, cached)
//Send response //Send response
res.header("Content-Type", "image/svg+xml") res.header("Content-Type", mime)
res.send(rendered) res.send(rendered)
} }
//Internal error //Internal error
@@ -167,7 +184,7 @@
`Cached time │ ${cached} seconds`, `Cached time │ ${cached} seconds`,
`Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth:Infinity, maxStringLength:256}) : "(enabled)"}`, `Rate limiter │ ${ratelimiter ? util.inspect(ratelimiter, {depth:Infinity, maxStringLength:256}) : "(enabled)"}`,
`Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`, `Max simultaneous users │ ${maxusers ? `${maxusers} users` : "(unrestricted)"}`,
`Plugins enabled │ ${enabled.join(", ")}`, `Plugins enabled │ ${enabled.map(({name}) => name).join(", ")}`,
`Server ready !` `Server ready !`
].join("\n"))) ].join("\n")))
} }

View File

@@ -75,7 +75,7 @@
}, },
templates:{ templates:{
list:templates, list:templates,
selected:url.get("template") || templates[0], selected:url.get("template") || templates[0].name,
loaded:{}, loaded:{},
placeholder:"", placeholder:"",
descriptions:{ descriptions:{

View File

@@ -28,9 +28,9 @@
<div class="step"> <div class="step">
<h2>2. Select a template</h2> <h2>2. Select a template</h2>
<div class="templates"> <div class="templates">
<label v-for="template in templates.list" :key="template" v-show="templates.descriptions[template] !== '(hidden)'"> <label v-for="template in templates.list" :key="template" v-show="templates.descriptions[template.name] !== '(hidden)'" :class="{'not-available':!template.enabled}" :title="!template.enabled ? 'This template is not enabled on web instance, use it with GitHub actions !' : ''">
<input type="radio" v-model="templates.selected" :value="template" @change="load" :disabled="generated.pending"> <input type="radio" v-model="templates.selected" :value="template.name" @change="load" :disabled="generated.pending">
{{ templates.descriptions[template] || template }} {{ templates.descriptions[template.name] || template.name }}
</label> </label>
</div> </div>
<template v-if="plugins.base.length"> <template v-if="plugins.base.length">
@@ -45,12 +45,11 @@
<template v-if="plugins.list.length"> <template v-if="plugins.list.length">
<h3>2.2 Enable additional plugins</h3> <h3>2.2 Enable additional plugins</h3>
<div class="plugins"> <div class="plugins">
<label v-for="plugin in plugins.list" :key="plugin"> <label v-for="plugin in plugins.list" :key="plugin" :class="{'not-available':!plugin.enabled}" :title="!plugin.enabled ? 'This plugin is not enabled on web instance, use it with GitHub actions !' : ''">
<input type="checkbox" v-model="plugins.enabled[plugin]" @change="load" :disabled="generated.pending"> <input type="checkbox" v-model="plugins.enabled[plugin.name]" @change="load" :disabled="(!plugin.enabled)||(generated.pending)">
{{ plugins.descriptions[plugin] || plugin }} {{ plugins.descriptions[plugin.name] || plugin.name }}
</label> </label>
</div> </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)"> <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> <h3>2.3 Configure additional plugins</h3>
<div class="options"> <div class="options">
@@ -58,59 +57,59 @@
<h4>{{ plugins.descriptions.tweets }}</h4> <h4>{{ plugins.descriptions.tweets }}</h4>
<label> <label>
Number of tweets to display Number of tweets to display
<input type="number" v-model="plugins.options['tweets.limit']" min="1" max="10" @change="load"> <input type="number" v-model="plugins.options['tweets.limit']" min="1" max="10" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
<div class="options-group" v-if="plugins.enabled.music"> <div class="options-group" v-if="plugins.enabled.music">
<h4>{{ plugins.descriptions.music }}</h4> <h4>{{ plugins.descriptions.music }}</h4>
<label> <label>
Playlist embed link Playlist embed link
<input type="text" v-model="plugins.options['music.playlist']" placeholder="https://embed.music.apple.com/en/playlist/"> <input type="text" v-model="plugins.options['music.playlist']" placeholder="https://embed.music.apple.com/en/playlist/" :disabled="generated.pending">
</label> </label>
<label> <label>
Number of tracks to display Number of tracks to display
<input type="number" v-model="plugins.options['music.limit']" min="1" @change="load"> <input type="number" v-model="plugins.options['music.limit']" min="1" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
<div class="options-group" v-if="plugins.enabled.pagespeed"> <div class="options-group" v-if="plugins.enabled.pagespeed">
<h4>{{ plugins.descriptions.pagespeed }}</h4> <h4>{{ plugins.descriptions.pagespeed }}</h4>
<label> <label>
Detailed PageSpeed report Detailed PageSpeed report
<input type="checkbox" v-model="plugins.options['pagespeed.detailed']" @change="load"> <input type="checkbox" v-model="plugins.options['pagespeed.detailed']" @change="load" :disabled="generated.pending">
</label> </label>
<label> <label>
Include a website screenshot Include a website screenshot
<input type="checkbox" v-model="plugins.options['pagespeed.screenshot']" @change="load"> <input type="checkbox" v-model="plugins.options['pagespeed.screenshot']" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
<div class="options-group" v-if="plugins.enabled.languages"> <div class="options-group" v-if="plugins.enabled.languages">
<h4>{{ plugins.descriptions.languages }}</h4> <h4>{{ plugins.descriptions.languages }}</h4>
<label> <label>
Ignored languages (comma separated) Ignored languages (comma separated)
<input type="text" v-model="plugins.options['languages.ignored']" @change="load"> <input type="text" v-model="plugins.options['languages.ignored']" @change="load" :disabled="generated.pending">
</label> </label>
<label> <label>
Skipped repositories (comma separated) Skipped repositories (comma separated)
<input type="text" v-model="plugins.options['languages.skipped']" @change="load"> <input type="text" v-model="plugins.options['languages.skipped']" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
<div class="options-group" v-if="plugins.enabled.habits"> <div class="options-group" v-if="plugins.enabled.habits">
<h4>{{ plugins.descriptions.habits }}</h4> <h4>{{ plugins.descriptions.habits }}</h4>
<label> <label>
Number of events for habits Number of events for habits
<input type="number" v-model="plugins.options['habits.from']" min="1" max="1000"> <input type="number" v-model="plugins.options['habits.from']" min="1" max="1000" :disabled="generated.pending">
</label> </label>
<label> <label>
Number of days for habits Number of days for habits
<input type="number" v-model="plugins.options['habits.days']" min="1" max="30"> <input type="number" v-model="plugins.options['habits.days']" min="1" max="30" :disabled="generated.pending">
</label> </label>
<label> <label>
Display tidbits Display tidbits
<input type="checkbox" v-model="plugins.options['habits.facts']" @change="load"> <input type="checkbox" v-model="plugins.options['habits.facts']" @change="load" :disabled="generated.pending">
</label> </label>
<label> <label>
Display activity charts Display activity charts
<input type="checkbox" v-model="plugins.options['habits.charts']" @change="load"> <input type="checkbox" v-model="plugins.options['habits.charts']" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
<div class="options-group" v-if="plugins.enabled.posts"> <div class="options-group" v-if="plugins.enabled.posts">
@@ -123,14 +122,14 @@
</label> </label>
<label> <label>
Number of posts to display Number of posts to display
<input type="number" v-model="plugins.options['posts.limit']" min="1" @change="load"> <input type="number" v-model="plugins.options['posts.limit']" min="1" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
<div class="options-group" v-if="plugins.enabled.isocalendar"> <div class="options-group" v-if="plugins.enabled.isocalendar">
<h4>{{ plugins.descriptions.isocalendar }}</h4> <h4>{{ plugins.descriptions.isocalendar }}</h4>
<label> <label>
Isocalendar duration Isocalendar duration
<select v-model="plugins.options['isocalendar.duration']"> <select v-model="plugins.options['isocalendar.duration']" :disabled="generated.pending">
<option value="half-year">Half year</option> <option value="half-year">Half year</option>
<option value="full-year">Full year</option> <option value="full-year">Full year</option>
</select> </select>
@@ -140,14 +139,14 @@
<h4>{{ plugins.descriptions.topics }}</h4> <h4>{{ plugins.descriptions.topics }}</h4>
<label> <label>
Topics display mode Topics display mode
<select v-model="plugins.options['topics.mode']" @change="load"> <select v-model="plugins.options['topics.mode']" @change="load" :disabled="generated.pending">
<option value="starred">Starred topics</option> <option value="starred">Starred topics</option>
<option value="mastered">Known and mastered technologies</option> <option value="mastered">Known and mastered technologies</option>
</select> </select>
</label> </label>
<label> <label>
Topics sorting Topics sorting
<select v-model="plugins.options['topics.sort']"> <select v-model="plugins.options['topics.sort']" :disabled="generated.pending">
<option value="starred">Recently starred by you</option> <option value="starred">Recently starred by you</option>
<option value="stars">Most stars</option> <option value="stars">Most stars</option>
<option value="activity">Recent actity</option> <option value="activity">Recent actity</option>
@@ -156,18 +155,18 @@
</label> </label>
<label> <label>
Number of topics to display Number of topics to display
<input type="number" v-model="plugins.options['topics.limit']" @change="load"> <input type="number" v-model="plugins.options['topics.limit']" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
<div class="options-group" v-if="plugins.enabled.projects"> <div class="options-group" v-if="plugins.enabled.projects">
<h4>{{ plugins.descriptions.projects }}</h4> <h4>{{ plugins.descriptions.projects }}</h4>
<label> <label>
Number of projects to display Number of projects to display
<input type="number" v-model="plugins.options['projects.limit']" min="1" max="100" @change="load"> <input type="number" v-model="plugins.options['projects.limit']" min="1" max="100" @change="load" :disabled="generated.pending">
</label> </label>
<label> <label>
Repositories projects to display (comma separated) Repositories projects to display (comma separated)
<input type="text" v-model="plugins.options['projects.repositories']" @change="load"> <input type="text" v-model="plugins.options['projects.repositories']" @change="load" :disabled="generated.pending">
</label> </label>
</div> </div>
</div> </div>

View File

@@ -82,6 +82,9 @@
transition: background-color .4s; transition: background-color .4s;
cursor: pointer; cursor: pointer;
} }
.not-available {
opacity: .3;
}
/* Generator */ /* Generator */
.generator { .generator {
display: flex; display: flex;

View File

@@ -6,11 +6,10 @@
if ((!enabled)||(!q.pagespeed)||(!data.user.websiteUrl)) if ((!enabled)||(!q.pagespeed)||(!data.user.websiteUrl))
return null return null
//Parameters override //Parameters override
let {"pagespeed.detailed":detailed = false, "pagespeed.screenshot":screenshot = false} = q let {"pagespeed.detailed":detailed = false, "pagespeed.screenshot":screenshot = false, "pagespeed.url":url = data.user.websiteUrl} = q
//Duration in days //Duration in days
detailed = !!detailed detailed = !!detailed
//Format url if needed //Format url if needed
let url = data.user.websiteUrl
if (!/^https?:[/][/]/.test(url)) if (!/^https?:[/][/]/.test(url))
url = `https://${url}` url = `https://${url}`
const result = {url, detailed, scores:[], metrics:{}} const result = {url, detailed, scores:[], metrics:{}}

View File

@@ -1,13 +1,12 @@
//Setup //Setup
export default async function ({imports, data, q}, {enabled = false} = {}) { export default async function ({login, imports, q}, {enabled = false} = {}) {
//Plugin execution //Plugin execution
try { try {
//Check if plugin is enabled and requirements are met //Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.posts)) if ((!enabled)||(!q.posts))
return null return null
//Parameters override //Parameters override
const login = data.user.login let {"posts.source":source = "", "posts.limit":limit = 4, "posts.user":user = login} = q
let {"posts.source":source = "", "posts.limit":limit = 4} = q
//Limit //Limit
limit = Math.max(1, Math.min(30, Number(limit))) limit = Math.max(1, Math.min(30, Number(limit)))
//Retrieve posts //Retrieve posts
@@ -17,7 +16,7 @@
//Dev.to //Dev.to
case "dev.to":{ case "dev.to":{
console.debug(`metrics/compute/${login}/plugins > posts > querying api`) console.debug(`metrics/compute/${login}/plugins > posts > querying api`)
posts = (await imports.axios.get(`https://dev.to/api/articles?username=${login}&state=fresh`)).data.map(({title, readable_publish_date:date}) => ({title, date})) posts = (await imports.axios.get(`https://dev.to/api/articles?username=${user}&state=fresh`)).data.map(({title, readable_publish_date:date}) => ({title, date}))
break break
} }
//Unsupported //Unsupported

View File

@@ -6,11 +6,10 @@
if ((!enabled)||(!q.tweets)) if ((!enabled)||(!q.tweets))
return null return null
//Parameters override //Parameters override
let {"tweets.limit":limit = 2} = q let {"tweets.limit":limit = 2, "tweets.user":username = data.user.twitterUsername} = q
//Limit //Limit
limit = Math.max(1, Math.min(10, Number(limit))) limit = Math.max(1, Math.min(10, Number(limit)))
//Load user profile //Load user profile
const username = data.user.twitterUsername
console.debug(`metrics/compute/${login}/plugins > tweets > loading twitter profile (@${username})`) console.debug(`metrics/compute/${login}/plugins > tweets > loading twitter profile (@${username})`)
const {data:{data:profile = null}} = await imports.axios.get(`https://api.twitter.com/2/users/by/username/${username}?user.fields=profile_image_url,verified`, {headers:{Authorization:`Bearer ${token}`}}) const {data:{data:profile = null}} = await imports.axios.get(`https://api.twitter.com/2/users/by/username/${username}?user.fields=profile_image_url,verified`, {headers:{Authorization:`Bearer ${token}`}})
//Load tweets //Load tweets

View File

@@ -1,22 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="<%= 12 <svg xmlns="http://www.w3.org/2000/svg" width="480" height="99999" class="<%= !animated ? 'no-animations' : '' %>">
+ (!!base.header)*80 + (user.isHireable)*16
+ (!!base.metadata)*38
+ ((!!base.activity)||(!!base.community))*128
+ (!!base.repositories)*108
+ ((!!base.repositories)*((!!plugins.traffic)||(!!plugins.lines)))*16
+ (!!plugins.followup)*68
+ (!!plugins.pagespeed)*126 + (plugins.pagespeed?.detailed ?? 0)*6*20 + (!!plugins.pagespeed?.screenshot)*330
+ (!!plugins.habits)*28 + (!!plugins.habits?.facts)*58 + (!!plugins.habits?.charts)*226
+ (!!plugins.languages)*96
+ (!!plugins.music)*64 + (plugins.music?.tracks?.length ? 14+Math.max(0, plugins.music.tracks.length-1)*36 : 0)
+ (!!plugins.posts)*64 + (plugins.posts?.list?.length ?? 0)*40
+ (!!plugins.isocalendar)*192 + (plugins.isocalendar?.duration === 'full-year')*100
+ (!!plugins.gists)*68
+ (!!plugins.topics)*160
+ (!!plugins.projects)*22 + (plugins.projects?.list?.length ?? 0)*60 + (!!plugins.projects?.error)*22
+ (!!plugins.tweets)*64 + (plugins.tweets?.list?.length ?? 0)*90
+ Math.max(0, (((!!base.metadata)+(!!base.header)+((!!base.activity)||(!!base.community))+(!!base.repositories)+((!!plugins.habits))+(!!plugins.pagespeed)+(!!plugins.languages)+(!!plugins.music)+(!!plugins.posts)+(!!plugins.isocalendar)+(!!plugins.gists)+(!!plugins.topics)+(!!plugins.projects))-1))*4
%>">
<defs><style><%= fonts %></style></defs> <defs><style><%= fonts %></style></defs>
@@ -802,6 +784,7 @@
</footer> </footer>
<% } %> <% } %>
<div id="metrics-end"></div>
</div> </div>
</foreignObject> </foreignObject>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -421,4 +421,18 @@
--color-calendar-graph-day-L3-border: rgba(27,31,35,0.06); --color-calendar-graph-day-L3-border: rgba(27,31,35,0.06);
--color-calendar-graph-day-L2-border: rgba(27,31,35,0.06); --color-calendar-graph-day-L2-border: rgba(27,31,35,0.06);
--color-calendar-graph-day-L1-border: rgba(27,31,35,0.06); --color-calendar-graph-day-L1-border: rgba(27,31,35,0.06);
}
/* End delimiter */
#metrics-end {
width: 100%;
}
.no-animations * {
transition-delay: 0s !important;
transition-duration: 0s !important;
animation-delay: -0.0001s !important;
animation-duration: 0s !important;
animation-play-state: paused !important;
caret-color: transparent !important;
} }

View File

@@ -18,6 +18,12 @@
} }
} }
//Animations
if ("config.animations" in q) {
data.animated = q["config.animations"]
console.debug(`metrics/compute/${login} > animations ${data.animated ? "enabled" : "disabled"}`)
}
//Plugins //Plugins
for (const name of Object.keys(imports.plugins)) { for (const name of Object.keys(imports.plugins)) {
if (!plugins[name].enabled) if (!plugins[name].enabled)

View File

@@ -1,11 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="<%= 0 <svg xmlns="http://www.w3.org/2000/svg" width="480" height="99999" class="<%= !animated ? 'no-animations' : '' %>">
+ (!!base.header)*42
+ (!!plugins.traffic)*18
+ (!!plugins.followup)*68
+ (!!base.metadata)*28
+ (!!plugins.projects)*22 + (plugins.projects?.list?.length ?? 0)*60 + (!!plugins.projects?.error)*22
+ Math.max(0, ((!!base.header)+(!!base.metadata)+(!!plugins.followup)+(!!plugins.projects))-1)*4
%>">
<defs><style><%= fonts %></style></defs> <defs><style><%= fonts %></style></defs>
@@ -216,6 +209,7 @@
</footer> </footer>
<% } %> <% } %>
<div id="metrics-end"></div>
</div> </div>
</foreignObject> </foreignObject>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -1,17 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="480" height="<%= 48 <svg xmlns="http://www.w3.org/2000/svg" width="480" height="99999" class="<%= !animated ? 'no-animations' : '' %>">
+ (!!base.header)*62
+ (!!base.metadata)*108
+ (!!base.activity)*108
+ (!!base.community)*94
+ (!!base.repositories)*142
+ ((!!base.repositories)*(!!plugins.traffic))*18
+ ((!!base.repositories)*(!!plugins.followup))*102
+ ((!!base.repositories)*(!!plugins.lines))*34
+ (!!plugins.pagespeed)*110 + (plugins.pagespeed?.detailed ?? 0)*6*16
+ (!!plugins.languages)*124
+ (!!plugins.gists)*58
+ Math.max(0, (((!!base.metadata)+(!!base.header)+((!!base.activity)||(!!base.community))+(!!base.repositories)+(!!plugins.pagespeed)+(!!plugins.languages)+(!!plugins.gists))-1))*20
%>">
<% <%
meta.$ = `<span class="ps1-path">${`${user.login}`.toLocaleLowerCase()}@metrics</span>:<span class="ps1-location">~</span>${computed.token.scopes.includes("repo") ? "#" : "$"}` meta.$ = `<span class="ps1-path">${`${user.login}`.toLocaleLowerCase()}@metrics</span>:<span class="ps1-location">~</span>${computed.token.scopes.includes("repo") ? "#" : "$"}`
meta.animations = !meta.placeholder ? {stdin:.16, stdout:.28, length:(2+Object.keys(base).length+Object.keys(plugins).length)} : {stdin:0, stdout:0, length:0} meta.animations = !meta.placeholder ? {stdin:.16, stdout:.28, length:(2+Object.keys(base).length+Object.keys(plugins).length)} : {stdin:0, stdout:0, length:0}
@@ -174,6 +161,7 @@ Total <%= plugins.gists.totalCount %> gist<%= s(plugins.gists.totalCount) %>
<footer>Connection reset by <%= Math.floor(256*Math.random()) %>.<%= Math.floor(256*Math.random()) %>.<%= Math.floor(256*Math.random()) %>.<%= Math.floor(256*Math.random()) %></footer><%# -%> <footer>Connection reset by <%= Math.floor(256*Math.random()) %>.<%= Math.floor(256*Math.random()) %>.<%= Math.floor(256*Math.random()) %>.<%= Math.floor(256*Math.random()) %></footer><%# -%>
<% } -%></pre> <% } -%></pre>
<div id="metrics-end"></div>
</div> </div>
</foreignObject> </foreignObject>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -56,6 +56,7 @@
} }
pre { pre {
background: #42092B; background: #42092B;
margin-top: 16px;
padding: 12px; padding: 12px;
border-radius: 5px; border-radius: 5px;
} }
@@ -124,4 +125,18 @@
--color-calendar-graph-day-L3-border: rgba(27,31,35,0.06); --color-calendar-graph-day-L3-border: rgba(27,31,35,0.06);
--color-calendar-graph-day-L2-border: rgba(27,31,35,0.06); --color-calendar-graph-day-L2-border: rgba(27,31,35,0.06);
--color-calendar-graph-day-L1-border: rgba(27,31,35,0.06); --color-calendar-graph-day-L1-border: rgba(27,31,35,0.06);
}
/* End delimiter */
#metrics-end {
width: 100%;
}
.no-animations * {
transition-delay: 0s !important;
transition-duration: 0s !important;
animation-delay: -0.0001s !important;
animation-duration: 0s !important;
animation-play-state: paused !important;
caret-color: transparent !important;
} }

View File

@@ -31,37 +31,66 @@
//Web instance //Web instance
const web = {} const web = {}
web.run = async (vars) => (await axios(`http://localhost:3000/lowlighter?${new url.URLSearchParams(Object.fromEntries(Object.entries(vars).map(([key, value]) => [key.replace(/^plugin_/, "").replace(/_/g, "."), value])))}`)).status === 200 web.run = async (vars) => (await axios(`http://localhost:3000/lowlighter?${new url.URLSearchParams(Object.fromEntries(Object.entries(vars).map(([key, value]) => [key.replace(/^plugin_/, "").replace(/_/g, "."), value])))}`)).status === 200
beforeAll(async () => await new Promise((solve, reject) => { beforeAll(async done => {
let stdout = "" await new Promise((solve, reject) => {
web.instance = processes.spawn("node", ["source/app/web/index.mjs"], {env:{...process.env, USE_MOCKED_DATA:true}}) let stdout = ""
web.instance.stdout.on("data", data => (stdout += data, /Server ready !/.test(stdout) ? solve() : null)) web.instance = processes.spawn("node", ["source/app/web/index.mjs"], {env:{...process.env, USE_MOCKED_DATA:true, NO_SETTINGS:true}})
web.instance.stderr.on("data", data => console.error(`${data}`)) web.instance.stdout.on("data", data => (stdout += data, /Server ready !/.test(stdout) ? solve() : null))
})) web.instance.stderr.on("data", data => console.error(`${data}`))
afterAll(async () => await web.instance.kill()) })
done()
})
afterAll(async done => {
await web.instance.kill("SIGKILL")
done()
})
//Test cases //Test cases
const tests = [ const tests = [
["Base (header)", { ["Base (header)", {
base:"header" base:"header",
base_header:true,
}], }],
["Base (activity", { ["Base (activity", {
base:"activity" base:"activity",
base_activity:true,
}], }],
["Base (community)", { ["Base (community)", {
base:"community" base:"community",
base_community:true,
}], }],
["Base (repositories)", { ["Base (repositories)", {
base:"repositories" base:"repositories",
base_repositories:true,
}], }],
["Base (metadata)", { ["Base (metadata)", {
base:"metadata" base:"metadata",
base_metadata:true,
}], }],
["Base (complete)", { ["Base (complete)", {
base:"header, activity, community, repositories, metadata" base:"header, activity, community, repositories, metadata",
base_header:true,
base_activity:true,
base_community:true,
base_repositories:true,
base_metadata:true,
}],
["Image output (jpeg)", {
config_output:"jpeg",
}],
["Image output (png)", {
config_output:"png",
}],
["Disable animations", {
config_animations:"no",
}], }],
["PageSpeed plugin (default)", { ["PageSpeed plugin (default)", {
plugin_pagespeed:true, plugin_pagespeed:true,
}, {skip:["repository"]}], }, {skip:["repository"]}],
["PageSpeed plugin (different url)", {
plugin_pagespeed:true,
plugin_pagespeed_url:"github.com",
}, {skip:["repository"]}],
["PageSpeed plugin (detailed)", { ["PageSpeed plugin (detailed)", {
plugin_pagespeed:true, plugin_pagespeed:true,
plugin_pagespeed_detailed:true, plugin_pagespeed_detailed:true,
@@ -179,6 +208,10 @@
["Tweets plugin (default)", { ["Tweets plugin (default)", {
plugin_tweets:true, plugin_tweets:true,
}, {skip:["terminal", "repository"]}], }, {skip:["terminal", "repository"]}],
["Tweets plugin (different user)", {
plugin_tweets:true,
plugin_tweets_user:"twitterdev",
}, {skip:["terminal", "repository"]}],
["Posts plugin (dev.to)", { ["Posts plugin (dev.to)", {
user:"lowlighter", user:"lowlighter",
plugin_posts:true, plugin_posts:true,