Use faker.js for mocked data and placeholder (#47)

This commit is contained in:
Simon Lecoq
2021-01-14 18:29:27 +01:00
committed by GitHub
parent c34e73fa68
commit d6e28e17bf
19 changed files with 1385 additions and 1070 deletions

View File

@@ -1,35 +1,40 @@
<!--
Thank you very much for contributing to this repository !
Link any related issues (if applicable) to help us keeping track of this project current state.
Don't forget to run `npm run test` to ensure that you'll pass status checks validation.
👋 Hi there!
Thanks for contributing to metrics and helping us to improve!
Please check the following before opening a pull request:
- It does not duplicate another existing pull request
- It is not mentioned in https://github.com/lowlighter/metrics/projects/1
- If it is, ensure that maintainers are aware that you're working on this subject
Then, explain briefly what your pull request is about and link any related issues (if applicable) to help us keeping track.
For documentation updates....
- Check spelling before asking for a review
- Respect current formatting (check that your editions blends well with current state)
- Static images must be saved in /.github/readme/imgs and must be of width 1260px
- UI should always be set in English in screenshots
For new plugins...
- Ensure that you created:
- a plugin entrypoint named index.mjs in /source/plugins
- tests in /tests/metrics.test.js
- mocked data if needed (required for all APIs which requires a token or limited in requests)
- Ensure you updated:
- /source/app/action/index.mjs to support new plugin options and use correct typing
- /source/web/statics/* to support new plugin options
- /settings.example.json with new plugin name
- README.md to explain new plugin features
- Include a screenshot in your pull request description
- You can use `&config.output=png` option in your web instance for it
For all code editions...
- Ensure retro-compatibility with previous versions (
- (unless for unreleased features, for which breaking changes are allowed)
- Respect current formatting
- Prefers using appropriate single words for variables and plugins names
- Avoid using uppercases letters, brackets and semicolons when possible to avoid visual pollution
- Comments should be added before each "code paragraph" and are considered indent worthy
-->
**Pull request description**
<!--
A clear and concise description of what your pull request implements or fixs.
Please check the following:
> Documentation update
- Check spelling
- Respect current formatting
> New plugin
- Created new plugin in /source/plugins/ with index.mjs as entry point
- Added tests in /tests/metrics.test.js
- Added mocked data if needed (required for all APIs which requires a token or limited in requests)
- Updated action.yml with new plugin options
- Updated /source/app/action/index.mjs to retrieve plugin options with correct typing
- Updated /source/web/statics/* to support new plugin options
- Updated /settings.example.json with new plugin name
- Updated README.md to explain plugin features
> Code editions
- Ensure retro-compatibility with previous versions
- Unless feature is not released yet
- Respect current formatting
-->
**Additional context and screenshots**
<!-- Add any other context or screenshots about your pull request here. -->

6
package-lock.json generated
View File

@@ -2690,6 +2690,12 @@
"integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=",
"dev": true
},
"faker": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/faker/-/faker-5.1.0.tgz",
"integrity": "sha512-RrWKFSSA/aNLP0g3o2WW1Zez7/MnMr7xkiZmoCfAGZmdkDQZ6l2KtuXHN5XjdvpRjDl8+3vf+Rrtl06Z352+Mw==",
"dev": true
},
"fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",

View File

@@ -6,7 +6,7 @@
"scripts": {
"start": "node source/app/web/index.mjs",
"test": "npx jest",
"upgrade": "npm install @actions/core@latest @actions/github@latest @octokit/graphql@latest @octokit/rest@latest axios@latest colors@latest compression@latest ejs@latest express@latest express-rate-limit@latest image-to-base64@latest memory-cache@latest prismjs@latest puppeteer@latest svgo@latest vue@latest vue-prism-component@latest jest@latest js-yaml@latest libxmljs@latest"
"upgrade": "npm install @actions/core@latest @actions/github@latest @octokit/graphql@latest @octokit/rest@latest axios@latest colors@latest compression@latest ejs@latest express@latest express-rate-limit@latest image-to-base64@latest memory-cache@latest prismjs@latest puppeteer@latest svgo@latest vue@latest vue-prism-component@latest faker@latest jest@latest js-yaml@latest libxmljs@latest"
},
"repository": {
"type": "git",
@@ -38,6 +38,7 @@
"vue-prism-component": "^1.2.0"
},
"devDependencies": {
"faker": "^5.1.0",
"jest": "^26.6.3",
"js-yaml": "^4.0.0",
"libxmljs": "^0.19.7"

View File

@@ -10,6 +10,7 @@
"port":3000, "//":"Listening port",
"optimize":true, "//":"Optimize SVG image",
"debug":false, "//":"Debug mode",
"mocked":false, "//":"Use mocked data",
"repositories":100, "//":"Number of repositories to use to compute metrics",
"templates":{ "//":"Template configuration",
"default":"classic", "//":"Default template",

View File

@@ -44,49 +44,43 @@
console.debug(`metrics/compute/${login} > content order : ${[...data.partials]}`)
}
//Placeholder
if (login === "placeholder")
placeholder({data, conf, q})
//Compute
else {
//Query data from GitHub API
console.debug(`metrics/compute/${login} > graphql query`)
const forks = q["repositories.forks"] || false
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(), forks:forks ? "" : ", isFork: false"})))
//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), forks:forks ? "" : ", isFork: false"}))
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)
//Query data from GitHub API
console.debug(`metrics/compute/${login} > graphql query`)
const forks = q["repositories.forks"] || false
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(), forks:forks ? "" : ", isFork: false"})))
//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), forks:forks ? "" : ", isFork: false"}))
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}))
}
}
//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
@@ -230,57 +224,3 @@
await page.close()
return {resized, mime}
}
/** 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(2).fill({title:"###### ###### ####### ######", date:"####"})},
music:{provider:"########", tracks:new Array(3).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(12).fill(null).map(() => ({name:"######", description:"", icon:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg=="})), {name:`And ## more...`, description:"", icon:null}]},
projects:{list:[...new Array(2).fill(null).map(() => ({name:"########", updated:"########", progress:{enabled:true, todo:"##", doing:"##", done:"##", total:"##"}}))]},
tweets:{profile:{username:"########", verified:false}, list:[...new Array(2).fill(null).map(() => ({text:"###### ###### ####### ######".repeat(4), created_at:Date.now()}))]},
stars:{repositories:[...new Array(2).fill({node:{nameWithOwner:"########/########", description:"###### ###### ####### ######".repeat(4)}})]},
activity:{events:[{type:"comment", on:"pr"}, {type:"public"}, {type:"release"}, {type:"issue"}]}
}[key]??{})]
)),
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -26,8 +26,11 @@
templates:{},
queries:{},
settings:{},
statics:__statics,
node_modules:__modules,
paths:{
statics:__statics,
templates:__templates,
node_modules:__modules,
}
}
//Load settings

View File

@@ -11,12 +11,12 @@
import metrics from "../metrics.mjs"
/** App */
export default async function ({mock = false, nosettings = false} = {}) {
export default async function ({mock, nosettings} = {}) {
//Load configuration settings
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
cache.placeholder = new cache.Cache()
mock = mock || conf.settings.mocked
//Apply configuration mocking if needed
if (mock) {
@@ -60,8 +60,7 @@
}
//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")
res.header("Cache-Control", cached ? `public, max-age=${cached}` : "no-store, no-cache")
next()
})
@@ -70,61 +69,62 @@
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).map(([name]) => ({name, enabled:plugins[name]?.enabled ?? false}))
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.sendFile(`${conf.statics}/favicon.png`))
app.get("/.favicon.png", limiter, (req, res) => res.sendFile(`${conf.statics}/favicon.png`))
app.get("/.opengraph.png", limiter, (req, res) => res.sendFile(`${conf.statics}/opengraph.png`))
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.vars.css", limiter, (req, res) => res.sendFile(`${conf.statics}/style.vars.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)
let requests = (await rest.rateLimit.get()).data.rate
setInterval(async () => requests = (await rest.rateLimit.get()).data.rate, 30*1000)
//Web
app.get("/", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/index.html`))
app.get("/index.html", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/index.html`))
app.get("/favicon.ico", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/favicon.png`))
app.get("/.favicon.png", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/favicon.png`))
app.get("/.opengraph.png", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/opengraph.png`))
//Plugins and 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("/.templates", limiter, (req, res) => res.status(200).json(templates))
app.get("/.templates/:template", limiter, (req, res) => req.params.template in conf.templates ? res.status(200).json(conf.templates[req.params.template]) : res.sendStatus(404))
for (const template in conf.templates)
app.use(`/.templates/${template}/partials`, express.static(`${conf.paths.templates}/${template}/partials`))
//Styles
app.get("/.css/style.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.css`))
app.get("/.css/style.vars.css", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/style.vars.css`))
app.get("/.css/style.prism.css", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/themes/prism-tomorrow.css`))
//Scripts
app.get("/.js/app.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/app.js`))
app.get("/.js/app.placeholder.js", limiter, (req, res) => res.sendFile(`${conf.paths.statics}/app.placeholder.js`))
app.get("/.js/ejs.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/ejs/ejs.min.js`))
app.get("/.js/faker.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/faker/dist/faker.min.js`))
app.get("/.js/axios.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/axios/dist/axios.min.js`))
app.get("/.js/axios.min.map", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/axios/dist/axios.min.map`))
app.get("/.js/vue.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/vue/dist/vue.min.js`))
app.get("/.js/vue.prism.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.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.paths.node_modules}/vue-prism-component/dist/vue-prism-component.min.js.map`))
app.get("/.js/prism.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/prism.js`))
app.get("/.js/prism.yaml.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/components/prism-yaml.min.js`))
app.get("/.js/prism.markdown.min.js", limiter, (req, res) => res.sendFile(`${conf.paths.node_modules}/prismjs/components/prism-markdown.min.js`))
//Meta
app.get("/.version", limiter, (req, res) => res.status(200).send(conf.package.version))
app.get("/.requests", limiter, async (req, res) => res.status(200).json(requests))
//Cache
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
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})
}
})
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) => {
//Placeholder hash
const placeholder = Object.entries(parse(req.query)).filter(([key, value]) =>
((key in Plugins)&&(!!value))||
((key === "template")&&(value in Templates))||
(/base[.](header|activity|community|repositories|metadata)/.test(key))||
(["pagespeed.detailed", "pagespeed.screenshot", "habits.charts", "habits.facts", "topics.mode"].includes(key))
).map(([key, value]) => `${key}${
key === "template" ? `#${value}` :
key === "topics.mode" ? `#${value === "mastered" ? value : "starred"}` :
!!value
}`).sort().join("+")
//Request params
const {login} = req.params
if ((restricted.length)&&(!restricted.includes(login))) {
@@ -132,13 +132,6 @@
return res.sendStatus(403)
}
//Read cached data if possible
//Placeholder
if ((login === "placeholder")&&(cache.placeholder.get(placeholder))) {
const {rendered, mime} = cache.placeholder.get(placeholder)
res.header("Content-Type", mime)
res.send(rendered)
return
}
//User cached
if ((!debug)&&(cached)&&(cache.get(login))) {
const {rendered, mime} = cache.get(login)
@@ -164,8 +157,6 @@
convert:["jpeg", "png"].includes(q["config.output"]) ? q["config.output"] : null
}, {Plugins, Templates})
//Cache
if (login === "placeholder")
cache.placeholder.put(placeholder, {rendered, mime})
if ((!debug)&&(cached))
cache.put(login, {rendered, mime}, cached)
//Send response
@@ -194,11 +185,13 @@
app.listen(port, () => console.log([
`Listening on port │ ${port}`,
`Debug mode │ ${debug}`,
`Mocked data │ ${conf.settings.mocked ?? false}`,
`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.map(({name}) => name).join(", ")}`,
`SVG optimization │ ${conf.settings.optimize ?? false}`,
`Server ready !`
].join("\n")))
}

View File

@@ -1,6 +1,5 @@
;(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")
@@ -10,12 +9,27 @@
//Initialization
el:"main",
async mounted() {
//Load instance
await this.load()
//Interpolate config from browser
try {
this.config.timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
this.palette = (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light")
} catch (error) {}
//GitHub limit tracker
const {data:requests} = await axios.get("/.requests")
this.requests = requests
setInterval(async () => {
const {data:requests} = await axios.get("/.requests")
this.requests = requests
}, 15000)
//Generate placeholder
this.mock({timeout:200})
setInterval(() => {
const marker = document.querySelector("#metrics-end")
if (marker) {
this.mockresize()
marker.remove()
}
}, 100)
},
components:{Prism:PrismComponent},
//Watchers
@@ -31,12 +45,14 @@
//Data initialization
data:{
version,
user:url.get("user") || "",
user:"",
tab:"overview",
palette:url.get("palette") || (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light") || "light",
palette:"light",
requests:{limit:0, used:0, remaining:0, reset:0},
cached:new Map(),
config:{
timezone:"",
animated:true,
},
plugins:{
base,
@@ -59,23 +75,47 @@
stars:"🌟 Recently starred repositories",
stargazers:"✨ Stargazers over last weeks",
activity:"📰 Recent activity",
"base.header":`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M0 8a8 8 0 1116 0v5.25a.75.75 0 01-1.5 0V8a6.5 6.5 0 10-13 0v5.25a.75.75 0 01-1.5 0V8zm5.5 4.25a.75.75 0 01.75-.75h3.5a.75.75 0 010 1.5h-3.5a.75.75 0 01-.75-.75zM3 6.75C3 5.784 3.784 5 4.75 5h6.5c.966 0 1.75.784 1.75 1.75v1.5A1.75 1.75 0 0111.25 10h-6.5A1.75 1.75 0 013 8.25v-1.5zm1.47-.53a.75.75 0 011.06 0l.97.97.97-.97a.75.75 0 011.06 0l.97.97.97-.97a.75.75 0 111.06 1.06l-1.5 1.5a.75.75 0 01-1.06 0L8 7.81l-.97.97a.75.75 0 01-1.06 0l-1.5-1.5a.75.75 0 010-1.06z"></path></svg>
Header`,
"base.activity":`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 1.75a.75.75 0 00-1.5 0v12.5c0 .414.336.75.75.75h14.5a.75.75 0 000-1.5H1.5V1.75zm14.28 2.53a.75.75 0 00-1.06-1.06L10 7.94 7.53 5.47a.75.75 0 00-1.06 0L3.22 8.72a.75.75 0 001.06 1.06L7 7.06l2.47 2.47a.75.75 0 001.06 0l5.25-5.25z"></path></svg>
Account activity`,
"base.community":`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.326 1.973a1.2 1.2 0 011.49-.832c.387.112.977.307 1.575.602.586.291 1.243.71 1.7 1.296.022.027.042.056.061.084A13.22 13.22 0 018 3c.67 0 1.289.037 1.861.108l.051-.07c.457-.586 1.114-1.004 1.7-1.295a9.654 9.654 0 011.576-.602 1.2 1.2 0 011.49.832c.14.493.356 1.347.479 2.29.079.604.123 1.28.07 1.936.541.977.773 2.11.773 3.301C16 13 14.5 15 8 15s-8-2-8-5.5c0-1.034.238-2.128.795-3.117-.08-.712-.034-1.46.052-2.12.122-.943.34-1.797.479-2.29zM8 13.065c6 0 6.5-2 6-4.27C13.363 5.905 11.25 5 8 5s-5.363.904-6 3.796c-.5 2.27 0 4.27 6 4.27z"></path><path d="M4 8a1 1 0 012 0v1a1 1 0 01-2 0V8zm2.078 2.492c-.083-.264.146-.492.422-.492h3c.276 0 .505.228.422.492C9.67 11.304 8.834 12 8 12c-.834 0-1.669-.696-1.922-1.508zM10 8a1 1 0 112 0v1a1 1 0 11-2 0V8z"></path></svg>
Community stats`,
"base.repositories":`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M2 2.5A2.5 2.5 0 014.5 0h8.75a.75.75 0 01.75.75v12.5a.75.75 0 01-.75.75h-2.5a.75.75 0 110-1.5h1.75v-2h-8a1 1 0 00-.714 1.7.75.75 0 01-1.072 1.05A2.495 2.495 0 012 11.5v-9zm10.5-1V9h-8c-.356 0-.694.074-1 .208V2.5a1 1 0 011-1h8zM5 12.25v3.25a.25.25 0 00.4.2l1.45-1.087a.25.25 0 01.3 0L8.6 15.7a.25.25 0 00.4-.2v-3.25a.25.25 0 00-.25-.25h-3.5a.25.25 0 00-.25.25z"></path></svg>
Repositories metrics`,
"base.metadata":`
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 1.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM0 8a8 8 0 1116 0A8 8 0 010 8zm6.5-.25A.75.75 0 017.25 7h1a.75.75 0 01.75.75v2.75h.25a.75.75 0 010 1.5h-2a.75.75 0 010-1.5h.25v-2h-.25a.75.75 0 01-.75-.75zM8 6a1 1 0 100-2 1 1 0 000 2z"></path></svg>
Metadata`,
people:"🧑‍🤝‍🧑 Followers and followed",
base:"🗃️ Base content",
"base.header":"Header",
"base.activity":"Account activity",
"base.community":"Community stats",
"base.repositories":"Repositories metrics",
"base.metadata":"Metadata",
},
options:{
descriptions:{
"languages.ignored":{text:"Ignored languages", placeholder:"lang-0, lang-1, ..."},
"languages.skipped":{text:"Skipped repositories", placeholder:"repo-0, repo-1, ..."},
"pagespeed.detailed":{text:"Detailed audit", type:"boolean"},
"pagespeed.screenshot":{text:"Audit screenshot", type:"boolean"},
"pagespeed.url":{text:"Url", placeholder:"(default to GitHub attached)"},
"habits.from":{text:"Events to use", type:"number", min:1, max:1000},
"habits.days":{text:"Max events age", type:"number", min:1, max:30},
"habits.facts":{text:"Display facts", type:"boolean"},
"habits.charts":{text:"Display charts", type:"boolean"},
"music.playlist":{text:"Playlist url", placeholder:"https://embed.music.apple.com/en/playlist/"},
"music.limit":{text:"Limit", type:"number", min:1, max:100},
"posts.limit":{text:"Limit", type:"number", min:1, max:30},
"posts.user":{text:"Username", placeholder:"(default to GitHub login)"},
"posts.source":{text:"Source", type:"select", values:["dev.to"]},
"isocalendar.duration":{text:"Duration", type:"select", values:["half-year", "full-year"]},
"projects.limit":{text:"Limit", type:"number", min:0, max:100},
"projects.repositories":{text:"Repositories projects", placeholder:"user/repo/projects/1, ..."},
"topics.mode":{text:"Mode", type:"select", values:["starred", "mastered"]},
"topics.sort":{text:"Sort by", type:"select", values:["starred", "activity", "stars", "random"]},
"topics.limit":{text:"Limit", type:"number", min:0, max:20},
"tweets.limit":{text:"Limit", type:"number", min:1, max:10},
"tweets.user":{text:"Username", placeholder:"(default to GitHub attached)"},
"stars.limit":{text:"Limit", type:"number", min:1, max:100},
"activity.limit":{text:"Limit", type:"number", min:1, max:100},
"activity.days":{text:"Max events age", type:"number", min:1, max:9999},
"activity.filter":{text:"Events type", placeholder:"all"},
"people.size":{text:"Limit", type:"number", min:16, max:64},
"people.limit":{text:"Limit", type:"number", min:1, max:9999},
"people.types":{text:"Types", placeholder:"followers, following"},
"people.identicons":{text:"Use identicons", type:"boolean"},
},
"languages.ignored":"",
"languages.skipped":"",
"pagespeed.detailed":false,
@@ -87,6 +127,7 @@
"music.playlist":"",
"music.limit":4,
"posts.limit":4,
"posts.user":"",
"posts.source":"dev.to",
"isocalendar.duration":"half-year",
"projects.limit":4,
@@ -95,15 +136,24 @@
"topics.sort":"stars",
"topics.limit":12,
"tweets.limit":2,
"tweets.user":"",
"stars.limit":4,
"activity.limit":5,
"activity.days":14,
"activity.filter":"all",
"people.size":28,
"people.limit":28,
"people.types":"followers, following",
"people.identicons":false,
},
},
templates:{
list:templates,
selected:url.get("template") || templates[0].name,
loaded:{},
placeholder:"",
selected:templates[0]?.name||"classic",
placeholder:{
timeout:null,
image:""
},
descriptions:{
classic:"Classic template",
terminal:"Terminal template",
@@ -120,7 +170,7 @@
computed:{
//User's avatar
avatar() {
return `https://github.com/${this.user}.png`
return this.generated.content ? `https://github.com/${this.user}.png` : null
},
//User's repository
repo() {
@@ -158,16 +208,18 @@
`on:`,
` # Schedule updates`,
` schedule: [{cron: "0 * * * *"}]`,
` push: {branches: "master"}`,
` # Lines below let you run workflow manually and on each commit`,
` push: {branches: ["master", "main"]}`,
` workflow_dispatch:`,
`jobs:`,
` github-metrics:`,
` runs-on: ubuntu-latest`,
` steps:`,
` - uses: lowlighter/metrics@latest`,
` with:`,
` # You'll need to setup a personal token in your secrets.`,
` # Your GitHub token`,
` token: ${"$"}{{ secrets.METRICS_TOKEN }}`,
` # GITHUB_TOKEN is a special auto-generated token used for commits`,
` # GITHUB_TOKEN is a special auto-generated token restricted to current repository, which is used to push files in it`,
` committer_token: ${"$"}{{ secrets.GITHUB_TOKEN }}`,
``,
` # Options`,
@@ -180,18 +232,40 @@
...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")
},
//Configurable plugins
configure() {
//Check enabled plugins
const enabled = Object.entries(this.plugins.enabled).filter(([key, value]) => (value)&&(key !== "base")).map(([key, value]) => key)
const filter = new RegExp(`^(?:${enabled.join("|")})[.]`)
//Search related options
const entries = Object.entries(this.plugins.options.descriptions).filter(([key, value]) => filter.test(key))
entries.push(...enabled.map(key => [key, this.plugins.descriptions[key]]))
entries.sort((a, b) => a[0].localeCompare(b[0]))
//Return object
const configure = Object.fromEntries(entries)
return Object.keys(configure).length ? configure : null
}
},
//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)
//Load and render placeholder image
async mock({timeout = 600} = {}) {
clearTimeout(this.templates.placeholder.timeout)
this.templates.placeholder.timeout = setTimeout(async () => {
this.templates.placeholder.image = await placeholder(this)
this.generated.content = ""
//Start GitHub rate limiter tracker
this.ghlimit()
this.generated.error = false
}, timeout)
},
//Resize mock image
mockresize() {
const svg = document.querySelector(".preview .image svg")
if (svg) {
const height = svg.querySelector("#metrics-end")?.getBoundingClientRect()?.y-svg.getBoundingClientRect()?.y
if (Number.isFinite(height))
svg.setAttribute("height", height)
}
},
//Generate metrics and flush cache
async generate() {
@@ -202,26 +276,15 @@
//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)
this.generated.content = (await axios.get(this.url)).data
this.generated.error = false
} 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,530 @@
(function () {
//Load asset
const cached = new Map()
async function load(url) {
if (!cached.has(url))
cached.set(url, (await axios.get(url)).data)
return cached.get(url)
}
//Distribution function
function distribution(length) {
let probability = 1
const values = []
for (let i = 0; i < length-1; i++) {
const value = Math.random()*probability
values.push(value)
probability -= value
}
values.push(probability)
return values.sort((a, b) => b - a)
}
//Placeholder function
window.placeholder = async function (set) {
//Load templates informations
let {image, style, fonts, partials} = await load(`/.templates/${set.templates.selected}`)
await Promise.all(partials.map(async partial => await load(`/.templates/${set.templates.selected}/partials/${partial}.ejs`)))
//Trap includes
image = image.replace(/<%-\s*await include[(](`.*?[.]ejs`)[)]\s*%>/g, (m, g) => `<%- await $include(${g}) %>`)
//Faked data
const options = set.plugins.options
const data = {
//Template elements
style, fonts, errors:[],
partials:new Set(partials),
//Plural helper
s(value, end = "") {
return value !== 1 ? {y:"ies", "":"s"}[end] : end
},
//Formatter helper
f(n, {sign = false} = {}) {
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 `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}`
return `${(sign)&&(n > 0) ? "+" : ""}${n}`
},
//Trap for includes
async $include(path) {
const partial = await load(`/.templates/${set.templates.selected}/${path}`)
return await ejs.render(partial, data, {async:true, rmWhitespace:true})
},
//Meta-data
meta:{version:set.version, author:"lowlighter"},
//Animated
animated:set.config.animated,
//Config
config:set.config,
//Base elements
base:set.plugins.enabled.base,
//Computed elements
computed: {
commits:faker.random.number(10000),
sponsorships:faker.random.number(10),
licenses:{favorite:[""], used:{MIT:1}},
token:{scopes:[]},
repositories: {
watchers:faker.random.number(1000),
stargazers:faker.random.number(10000),
issues_open:faker.random.number(1000),
issues_closed:faker.random.number(1000),
pr_open:faker.random.number(1000),
pr_merged:faker.random.number(1000),
forks:faker.random.number(1000),
releases:faker.random.number(1000),
},
diskUsage:`${faker.random.float({min:1, max:999}).toFixed(1)}MB`,
registration:`${faker.random.number({min:2, max:10})} years ago`,
cakeday:false,
calendar:new Array(14).fill(null).map(_ => ({color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])})),
avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg=="
},
//User data
user:{
databaseId:faker.random.number(10000000),
name:"(placeholder)",
login:set.user||"metrics",
createdAt:`${faker.date.past(10)}`,
avatarUrl:set.avatar,
websiteUrl:options["pagespeed.url"]||"(attached website)",
isHireable:false,
twitterUsername:options["tweets.user"]||"(attached Twitter account)",
repositories:{totalCount:faker.random.number(100), totalDiskUsage:faker.random.number(100000), nodes:[]},
packages:{totalCount:faker.random.number(10)},
starredRepositories:{totalCount:faker.random.number(1000)},
watching:{totalCount:faker.random.number(100)},
sponsorshipsAsSponsor:{totalCount:faker.random.number(10)},
sponsorshipsAsMaintainer:{totalCount:faker.random.number(10)},
contributionsCollection:{
totalRepositoriesWithContributedCommits:faker.random.number(100),
totalCommitContributions:faker.random.number(10000),
restrictedContributionsCount:faker.random.number(10000),
totalIssueContributions:faker.random.number(100),
totalPullRequestContributions:faker.random.number(1000),
totalPullRequestReviewContributions:faker.random.number(1000),
},
calendar:{contributionCalendar:{weeks:[]}},
repositoriesContributedTo:{totalCount:faker.random.number(100)},
followers:{totalCount:faker.random.number(1000)},
following:{totalCount:faker.random.number(1000)},
issueComments:{totalCount:faker.random.number(1000)},
organizations:{totalCount:faker.random.number(10)}
},
//Plugins
plugins:{
//Tweets
...(set.plugins.enabled.tweets ? ({
tweets:{
username:options["tweets.user"]||"(attached Twitter account)",
profile:{
profile_image_url:faker.image.people(),
name:"",
verified:false,
id:faker.random.number(1000000).toString(),
username:options["tweets.user"]||"(attached Twitter account)",
profile_image:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
},
list:[
{
id:faker.random.number(100000000000000).toString(),
created_at:faker.date.recent(),
entities: {
mentions: [ { start: 22, end: 33, username: 'lowlighter' } ]
},
text: 'Checkout metrics from <span class="mention">@lowlighter</span> ! <span class="hashtag">#GitHub</span> ',
mentions: [ 'lowlighter' ]
},
...new Array(Number(options["tweets.limit"])-1).fill(null).map(_ => ({
id:faker.random.number(100000000000000).toString(),
created_at:faker.date.recent(),
text:faker.lorem.paragraph(),
mentions:[]
})),
]
}
}) : null),
//Lines
...(set.plugins.enabled.lines ? ({
lines:{
added:`${faker.random.number(100)}.${faker.random.number(9)}k`,
deleted:`${faker.random.number(100)}.${faker.random.number(9)}k`,
}
}) : null),
//Traffic
...(set.plugins.enabled.traffic ? ({
traffic:{
views:{
count:`${faker.random.number({min:10, max:100})}.${faker.random.number(9)}k`,
uniques:`${faker.random.number(10)}.${faker.random.number(9)}k`,
}
}
}) : null),
//Follow-up
...(set.plugins.enabled.followup ? ({
followup:{
issues:{get count() { return this.open + this.closed }, open:faker.random.number(1000), closed:faker.random.number(1000)},
pr:{get count() { return this.open + this.merged }, open:faker.random.number(1000), merged:faker.random.number(1000)},
}
}) : null),
//Gists
...(set.plugins.enabled.gists ? ({
gists:{
totalCount:faker.random.number(100),
stargazers:faker.random.number(1000),
forks:faker.random.number(100),
files:faker.random.number(100),
comments:faker.random.number(1000)
}
}) : null),
//Languages
...(set.plugins.enabled.languages ? ({
languages:{
get colors() { return Object.fromEntries(Object.entries(this.favorites).map(([key, {color}]) => [key, color])) },
total:faker.random.number(10000),
get stats() { return Object.fromEntries(Object.entries(this.favorites).map(([key, {value}]) => [key, value])) },
favorites:distribution(7).map((value, index, array) => ({name:faker.lorem.word(), color:faker.internet.color(), value, x:array.slice(0, index).reduce((a, b) => a + b, 0)}))
}
}) : null),
//Habits
...(set.plugins.enabled.habits ? ({
habits:{
facts:options["habits.facts"],
charts:options["habits.charts"],
commits:{
get hour() { return Object.keys(this.hours).filter(key => /^\d+$/.test(key)).map(key => [key, this.hours[key]]).sort((a, b) => b[1] - a[1]).shift()?.[0] },
hours:{
[faker.random.number(24)]:faker.random.number(10),
[faker.random.number(24)]:faker.random.number(10),
[faker.random.number(24)]:faker.random.number(10),
[faker.random.number(24)]:faker.random.number(10),
[faker.random.number(24)]:faker.random.number(10),
[faker.random.number(24)]:faker.random.number(10),
[faker.random.number(24)]:faker.random.number(10),
get max() { return Object.keys(this).filter(key => /^\d+$/.test(key)).map(key => [key, this[key]]).sort((a, b) => b[1] - a[1]).shift()?.[1] }
},
get day() { return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][Object.keys(this.days).filter(key => /^\d+$/.test(key)).map(key => [key, this.days[key]]).sort((a, b) => b[1] - a[1]).shift()?.[0]] },
days:{
"0":faker.random.number(10),
"1":faker.random.number(10),
"2":faker.random.number(10),
"3":faker.random.number(10),
"4":faker.random.number(10),
"5":faker.random.number(10),
"6":faker.random.number(10),
get max() { return Object.keys(this).filter(key => /^\d+$/.test(key)).map(key => [key, this[key]]).sort((a, b) => b[1] - a[1]).shift()?.[1] }
},
},
indents:{style:"spaces", spaces:1, tabs:0},
linguist:{
available:true,
get ordered() { return Object.entries(this.languages) },
get languages() { return Object.fromEntries(distribution(4).map(value => [faker.lorem.word(), value])) },
}
}
}) : null),
//People
...(set.plugins.enabled.people ? ({
people:{
types:options["people.types"].split(",").map(x => x.trim()),
size:options["people.size"],
followers:new Array(Number(options["people.limit"])).fill(null).map(_ => ({
login:faker.internet.userName(),
avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
})),
following:new Array(Number(options["people.limit"])).fill(null).map(_ => ({
login:faker.internet.userName(),
avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
}))
}
}) : null),
//Music
...(set.plugins.enabled.music ? ({
music:{
provider:"(music provider)",
mode:"Suggested tracks",
tracks:new Array(Number(options["music.limit"])).fill(null).map(_ => ({
name:faker.random.words(5),
artist:faker.random.words(),
artwork:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
}))
}
}) : null),
//Pagespeed
...(set.plugins.enabled.pagespeed ? ({
pagespeed:{
url:options["pagespeed.url"]||"(attached website url)",
detailed:options["pagespeed.detailed"]||false,
scores: [
{score:faker.random.float({max:1}), title:"Performance"},
{score:faker.random.float({max:1}), title:"Accessibility"},
{score:faker.random.float({max:1}), title:"Best Practices"},
{score:faker.random.float({max:1}), title:"SEO"}
],
metrics:{
observedFirstContentfulPaint:faker.random.number(500),
observedFirstVisualChangeTs:faker.time.recent(),
observedFirstContentfulPaintTs:faker.time.recent(),
firstContentfulPaint:faker.random.number(500),
observedDomContentLoaded:faker.random.number(500),
observedFirstMeaningfulPaint:faker.random.number(1000),
maxPotentialFID:faker.random.number(500),
observedLoad:faker.random.number(500),
firstMeaningfulPaint:faker.random.number(500),
observedCumulativeLayoutShift:faker.random.float({max:1}),
observedSpeedIndex:faker.random.number(1000),
observedSpeedIndexTs:faker.time.recent(),
observedTimeOriginTs:faker.time.recent(),
observedLargestContentfulPaint:faker.random.number(1000),
cumulativeLayoutShift:faker.random.float({max:1}),
observedFirstPaintTs:faker.time.recent(),
observedTraceEndTs:faker.time.recent(),
largestContentfulPaint:faker.random.number(2000),
observedTimeOrigin:faker.random.number(10),
speedIndex:faker.random.number(1000),
observedTraceEnd:faker.random.number(2000),
observedDomContentLoadedTs:faker.time.recent(),
observedFirstPaint:faker.random.number(500),
totalBlockingTime:faker.random.number(500),
observedLastVisualChangeTs:faker.time.recent(),
observedFirstVisualChange:faker.random.number(500),
observedLargestContentfulPaintTs:faker.time.recent(),
estimatedInputLatency:faker.random.number(100),
observedLoadTs:faker.time.recent(),
observedLastVisualChange:faker.random.number(1000),
firstCPUIdle:faker.random.number(1000),
interactive:faker.random.number(1000),
observedNavigationStartTs:faker.time.recent(),
observedNavigationStart:faker.random.number(10),
observedFirstMeaningfulPaintTs:faker.time.recent()
},
screenshot:options["pagespeed.screenshot"] ? "data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==" : null
}
}) : null),
//Projects
...(set.plugins.enabled.projects ? ({
projects:{
totalCount:options["projects.limit"]+faker.random.number(10),
list:new Array(Number(options["projects.limit"])).fill(null).map(_ => ({
name:faker.lorem.sentence(),
updated:`${2+faker.date.recent(8)} days ago`,
progress:{enabled:true, todo:faker.random.number(50), doing:faker.random.number(50), done:faker.random.number(50), get total() { return this.todo + this.doing + this.done } }
}))
}
}) : null),
//Posts
...(set.plugins.enabled.posts ? ({
posts:{
source:options["posts.source"],
list:new Array(Number(options["posts.limit"])).fill(null).map(_ => ({
title:faker.lorem.sentence(),
date:faker.date.recent().toString().substring(4, 10).trim()
}))
}
}) : null),
//Topics
...(set.plugins.enabled.topics ? ({
topics:{
mode:options["topics.mode"],
list:new Array(Number(options["topics.limit"])||20).fill(null).map(_ => ({
name:faker.lorem.words(2),
description:faker.lorem.sentence(),
icon:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg=="
}))
}
}) : null),
//Stars
...(set.plugins.enabled.stars ? ({
stars:{
repositories: [
{
starredAt:faker.date.recent(),
node: {
description:"📊 An image generator with 20+ metrics about your GitHub account such as activity, community, repositories, coding habits, website performances, music played, starred topics, etc. that you can put on your profile or elsewhere !",
forkCount:faker.random.number(100),
isFork:false,
issues:{
totalCount:faker.random.number(100),
},
nameWithOwner:"lowlighter/metrics",
openGraphImageUrl:"https://repository-images.githubusercontent.com/293860197/7fd72080-496d-11eb-8fe0-238b38a0746a",
pullRequests:{
totalCount:faker.random.number(100),
},
stargazerCount:faker.random.number(10000),
licenseInfo:{nickname:null, name:"MIT License"},
primaryLanguage:{color:"#f1e05a", name:"JavaScript"}
},
starred:"1 day ago"
},
...new Array(Number(options["stars.limit"])-1).fill(null).map((_, i) => ({
starredAt:faker.date.recent(),
node: {
description:faker.lorem.sentence(),
forkCount:faker.random.number(100),
isFork:faker.random.boolean(),
issues:{
totalCount:faker.random.number(100),
},
nameWithOwner:`${faker.random.word()}/${faker.random.word()}`,
openGraphImageUrl:faker.internet.url(),
pullRequests:{
totalCount:faker.random.number(100),
},
stargazerCount:faker.random.number(10000),
licenseInfo:{nickname:null, name:"License"},
primaryLanguage:{color:faker.internet.color(), name:faker.lorem.word()}
},
starred:`${i+2} days ago`
})),
]
}
}) : null),
//Stars
...(set.plugins.enabled.stargazers ? ({
get stargazers() {
const dates = []
let total = faker.random.number(1000)
const result = {
total:{
dates:{},
get max() { return Math.max(...dates.map(date => this.dates[date])) },
get min() { return Math.min(...dates.map(date => this.dates[date])) },
},
increments:{
dates:{},
get max() { return Math.max(...dates.map(date => this.dates[date])) },
get min() { return Math.min(...dates.map(date => this.dates[date])) },
},
months:["", "Jan.", "Feb.", "Mar.", "Apr.", "May", "June", "July", "Aug.", "Sep.", "Oct.", "Nov.", "Dec."]
}
for (let d = -14; d <= 0; d++) {
const date = new Date(Date.now()-d*24*60*60*1000).toISOString().substring(0, 10)
dates.push(date)
result.total.dates[date] = (total += (result.increments.dates[date] = faker.random.number(100)))
}
return result
}
}) : null),
//Activity
...(set.plugins.enabled.activity ? ({
activity:{
events:new Array(Number(options["activity.limit"])).fill(null).map(_ => [
{
type:"push",
repo:`${faker.random.word()}/${faker.random.word()}`,
size:1,
branch:"master",
commits: [ { sha:faker.git.shortSha(), message:faker.lorem.sentence()} ]
},
{
type:"comment",
on:"commit",
repo:`${faker.random.word()}/${faker.random.word()}`,
content:faker.lorem.paragraph(),
user:set.user,
mobile:null,
number:faker.git.shortSha(),
title:"",
},
{
type:"comment",
on:"pr",
repo:`${faker.random.word()}/${faker.random.word()}`,
content:faker.lorem.sentence(),
user:set.user,
mobile:null,
number:faker.random.number(100),
title:faker.lorem.paragraph(),
},
{
type:"comment",
on:"issue",
repo:`${faker.random.word()}/${faker.random.word()}`,
content:faker.lorem.sentence(),
user:set.user,
mobile:null,
number:faker.random.number(100),
title:faker.lorem.paragraph(),
},
{
type:"issue",
repo:`${faker.random.word()}/${faker.random.word()}`,
action:faker.random.arrayElement(["opened", "closed", "reopened"]),
user:set.user,
number:faker.random.number(100),
title:faker.lorem.paragraph(),
},
{
type:"pr",
repo:`${faker.random.word()}/${faker.random.word()}`,
action:faker.random.arrayElement(["opened", "closed"]),
user:set.user,
number:faker.random.number(100),
title:faker.lorem.paragraph(),
lines:{added:faker.random.number(1000), deleted:faker.random.number(1000)}, files:{changed:faker.random.number(10)}
},
{
type:"wiki",
repo:`${faker.random.word()}/${faker.random.word()}`,
pages:[faker.lorem.sentence(), faker.lorem.sentence()]
},
{
type:"fork",
repo:`${faker.random.word()}/${faker.random.word()}`,
},
{
type:"review",
repo:`${faker.random.word()}/${faker.random.word()}`,
user:set.user,
number:faker.random.number(100),
title:faker.lorem.paragraph(),
},
{
type:"release",
repo:`${faker.random.word()}/${faker.random.word()}`,
action:"published",
name:faker.random.words(4),
draft:faker.random.boolean(),
prerelease:faker.random.boolean(),
},
{
type:"ref/create",
repo:`${faker.random.word()}/${faker.random.word()}`,
ref:{name:faker.lorem.slug(), type:faker.random.arrayElement(["tag", "branch"]),}
},
{
type:"ref/delete",
repo:`${faker.random.word()}/${faker.random.word()}`,
ref:{name:faker.lorem.slug(), type:faker.random.arrayElement(["tag", "branch"]),}
},
{
type:"member",
repo:`${faker.random.word()}/${faker.random.word()}`,
user:set.user
},
{
type:"public",
repo:`${faker.random.word()}/${faker.random.word()}`,
},
{
type:"star",
repo:`${faker.random.word()}/${faker.random.word()}`,
action:"started"
},
][Math.floor(Math.random()*15)])
}
}) : null),
//Isocalendar
...(set.plugins.enabled.isocalendar ? ({
isocalendar:{
streak:{max:30+faker.random.number(20), current:faker.random.number(30)},
max:10+faker.random.number(40),
average:faker.random.float(10),
svg:"(isometric calendar is not displayed in placeholder)",
duration:options["isocalendar.duration"]
}
}) : null),
},
}
//Render
return await ejs.render(image, data, {async:true, rmWhitespace:true})
}
})()

View File

@@ -16,289 +16,112 @@
<main :class="[palette]">
<template>
<!-- Title -->
<h1 class="title">
<a href="https://github.com/lowlighter/metrics">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>
Metrics v{{ version }}
</a>
</h1>
<header>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path></svg>
<a href="https://github.com/lowlighter/metrics">Metrics v{{ version }}</a>
</header>
<!-- Tabs -->
<nav>
<div class="left"></div>
<div class="right">
<div @click="tab = 'overview'" class="tab" :class="{active:tab === 'overview'}">
<div class="ui top">
<aside></aside>
<nav>
<div @click="tab = 'overview'" :class="{active:tab === 'overview'}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M0 1.75A.75.75 0 01.75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0111.006 1h4.245a.75.75 0 01.75.75v10.5a.75.75 0 01-.75.75h-4.507a2.25 2.25 0 00-1.591.659l-.622.621a.75.75 0 01-1.06 0l-.622-.621A2.25 2.25 0 005.258 13H.75a.75.75 0 01-.75-.75V1.75zm8.755 3a2.25 2.25 0 012.25-2.25H14.5v9h-3.757c-.71 0-1.4.201-1.992.572l.004-7.322zm-1.504 7.324l.004-5.073-.002-2.253A2.25 2.25 0 005.003 2.5H1.5v9h3.757a3.75 3.75 0 011.994.574z"></path></svg>
Overview
</div>
<div @click="tab = 'action'" class="tab" :class="{active:tab === 'action', disabled:!user}">
<div @click="generated.content ? tab = 'action' : null" :class="{active:tab === 'action', disabled:!generated.content}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"></path></svg>
Action
Action code
</div>
<div @click="tab = 'markdown'" class="tab" :class="{active:tab === 'markdown', disabled:!user}">
<div @click="generated.content ? tab = 'markdown' : null" :class="{active:tab === 'markdown', disabled:!generated.content}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0114.25 15h-9a.75.75 0 010-1.5h9a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 0110 4.25V1.5H5.75a.25.25 0 00-.25.25v2.5a.75.75 0 01-1.5 0v-2.5zm7.5-.188V4.25c0 .138.112.25.25.25h2.688a.252.252 0 00-.011-.013l-2.914-2.914a.272.272 0 00-.013-.011zM5.72 6.72a.75.75 0 000 1.06l1.47 1.47-1.47 1.47a.75.75 0 101.06 1.06l2-2a.75.75 0 000-1.06l-2-2a.75.75 0 00-1.06 0zM3.28 7.78a.75.75 0 00-1.06-1.06l-2 2a.75.75 0 000 1.06l2 2a.75.75 0 001.06-1.06L1.81 9.25l1.47-1.47z"></path></svg>
Markdown
Markdown code
</div>
</div>
</nav>
</nav>
</div>
<section class="container">
<!-- Left section -->
<div class="left">
<div class="ui">
<!-- Avatar -->
<div class="avatar">
<div :style="{backgroundImage:`url(${avatar})`}"></div>
<aside>
<div class="ui-avatar" :style="{backgroundImage:avatar ? `url(${avatar})` : 'none'}"></div>
<input type="text" v-model="user" placeholder="Your GitHub username" @keyup="mock">
<button @click="generate" :disabled="(!user)||(generated.pending)">
{{ generated.pending ? 'Working on it :)' : 'Generate your metrics!' }}
</button>
<small>{{ requests.limit }} GitHub requests remaining</small>
<div class="configuration">
<b>🖼️ Template</b>
<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 this web instance, use GitHub actions instead!' : ''">
<input type="radio" v-model="templates.selected" :value="template.name" @change="mock" :disabled="generated.pending">
{{ templates.descriptions[template.name] || template.name }}
</label>
</div>
<!-- User -->
<div class="user step">
<input type="text" name="user" v-model="user" maxlength="39" placeholder="Your GitHub username" :disabled="generated.pending">
<button @click="generate" :disabled="(!user)||(generated.pending)">{{ generated.pending ? "Working on it :)" : "Generate your metrics !" }}</button>
<!-- GitHub requests tracker -->
<div class="gh-requests">{{ requests.remaining }} GitHub request{{ requests.remaining > 1 ? "s" : "" }} remaining</div>
<div class="configuration" v-if="plugins.base.length">
<b>🗃️ Base content</b>
<label v-for="part in plugins.base" :key="part">
<input type="checkbox" v-model="plugins.enabled.base[part]" @change="mock" :disabled="generated.pending">
<span>{{ plugins.descriptions[`base.${part}`] || `base.${part}` }}</span>
</label>
</div>
<!-- Scrollable part -->
<div class="scrollable">
<div class="configuration" v-if="plugins.list.length">
<b>🧩 Additional plugins</b>
<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.name]" @change="mock" :disabled="(!plugin.enabled)||(generated.pending)">
{{ plugins.descriptions[plugin.name] || plugin.name }}
</label>
</div>
<!-- Template -->
<div class="step">
<h2>🖼️ Template</h2>
<div class="templates">
<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.name" @change="load" :disabled="generated.pending">
{{ templates.descriptions[template.name] || template.name }}
</label>
</div>
<div class="configuration" v-if="configure">
<b>🔧 Configure plugins</b>
<template v-for="(input, key) in configure">
<b v-if="typeof input === 'string'">{{ input }}</b>
<label v-else class="option">
<i>{{ input.text }}</i>
<input type="checkbox" v-if="input.type === 'boolean'" v-model="plugins.options[key]" @change="mock">
<input type="number" v-else-if="input.type === 'number'" v-model="plugins.options[key]" @change="mock" :min="input.min" :max="input.max">
<select v-else-if="input.type === 'select'" v-model="plugins.options[key]" @change="mock">
<option v-for="value in input.values" :value="value">{{ value }}</option>
</select>
<input type="text" v-else v-model="plugins.options[key]" @change="mock" :placeholder="input.placeholder">
</label>
</template>
</div>
</aside>
<div class="preview">
<div class="readme">
<svg viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M1.326 1.973a1.2 1.2 0 011.49-.832c.387.112.977.307 1.575.602.586.291 1.243.71 1.7 1.296.022.027.042.056.061.084A13.22 13.22 0 018 3c.67 0 1.289.037 1.861.108l.051-.07c.457-.586 1.114-1.004 1.7-1.295a9.654 9.654 0 011.576-.602 1.2 1.2 0 011.49.832c.14.493.356 1.347.479 2.29.079.604.123 1.28.07 1.936.541.977.773 2.11.773 3.301C16 13 14.5 15 8 15s-8-2-8-5.5c0-1.034.238-2.128.795-3.117-.08-.712-.034-1.46.052-2.12.122-.943.34-1.797.479-2.29zM8 13.065c6 0 6.5-2 6-4.27C13.363 5.905 11.25 5 8 5s-5.363.904-6 3.796c-.5 2.27 0 4.27 6 4.27z"></path><path d="M4 8a1 1 0 012 0v1a1 1 0 01-2 0V8zm2.078 2.492c-.083-.264.146-.492.422-.492h3c.276 0 .505.228.422.492C9.67 11.304 8.834 12 8 12c-.834 0-1.669-.696-1.922-1.508zM10 8a1 1 0 112 0v1a1 1 0 11-2 0V8z"></path></svg>
<span>{{ user }}</span><span class="slash">/</span>README<span class="md">.md</span>
</div>
<div v-if="tab == 'overview'">
<div class="error" v-if="generated.error">An error occurred while generating your metrics :( Please try again later.</div>
<div class="image" :class="{pending:generated.pending}" v-html="generated.content||templates.placeholder.image"></div>
</div>
<div v-else-if="tab == 'markdown'">
Add the markdown below to your <i>README.md</i> at <a :href="repo">{{ user }}/{{ user }}</a>
<div class="code">
<Prism language="markdown" :code="embed"></Prism>
</div>
<!-- Palette -->
<div class="step">
<h2>🌎 Color palette</h2>
<div class="templates">
<label>
<input type="radio" v-model="palette" value="light">
☀️ Light
</label>
<label>
<input type="radio" v-model="palette" value="dark">
🌕 Dark
</label>
</div>
</div>
<!-- Base content -->
<div class="step" v-if="plugins.base.length">
<h2>🗃️ Base content</h2>
<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">
<span v-html="plugins.descriptions[`base.${part}`] || `base.${part}`"></span>
</label>
</div>
</div>
<!-- Plugins -->
<div class="step" v-if="plugins.list.length">
<h2>🧩 Additional plugins</h2>
<div class="plugins">
<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.name]" @change="load" :disabled="(!plugin.enabled)||(generated.pending)">
{{ plugins.descriptions[plugin.name] || plugin.name }}
</label>
</div>
</div>
<!-- Plugins options -->
<div class="step" 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)||(plugins.enabled.activity)">
<h2>🔧 Configure plugins</h2>
<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" :disabled="generated.pending">
</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/" :disabled="generated.pending">
</label>
<label>
Number of tracks to display
<input type="number" v-model="plugins.options['music.limit']" min="1" @change="load" :disabled="generated.pending">
</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" :disabled="generated.pending">
</label>
<label>
Include a website screenshot
<input type="checkbox" v-model="plugins.options['pagespeed.screenshot']" @change="load" :disabled="generated.pending">
</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" :disabled="generated.pending">
</label>
<label>
Skipped repositories (comma separated)
<input type="text" v-model="plugins.options['languages.skipped']" @change="load" :disabled="generated.pending">
</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" :disabled="generated.pending">
</label>
<label>
Number of days for habits
<input type="number" v-model="plugins.options['habits.days']" min="1" max="30" :disabled="generated.pending">
</label>
<label>
Display tidbits
<input type="checkbox" v-model="plugins.options['habits.facts']" @change="load" :disabled="generated.pending">
</label>
<label>
Display activity charts
<input type="checkbox" v-model="plugins.options['habits.charts']" @change="load" :disabled="generated.pending">
</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" :disabled="generated.pending">
</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']" :disabled="generated.pending">
<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" :disabled="generated.pending">
<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']" :disabled="generated.pending">
<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" :disabled="generated.pending">
</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" :disabled="generated.pending">
</label>
<label>
Repositories projects to display (comma separated)
<input type="text" v-model="plugins.options['projects.repositories']" @change="load" :disabled="generated.pending">
</label>
</div>
<div class="options-group" v-if="plugins.enabled.activity">
<h4>{{ plugins.descriptions.activity }}</h4>
<label>
Number of activity events to display
<input type="number" v-model="plugins.options['activity.limit']" min="1" max="10" @change="load" :disabled="generated.pending">
</label>
</div>
</div>
</div>
<div v-else-if="tab == 'action'">
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>
</div>
</div>
<!-- Right section -->
<div class="right">
</div>
<!-- Tabs (mobile screen)-->
<nav class="mobile flex">
<div class="left"></div>
<div class="right">
<div @click="tab = 'overview'" class="tab" :class="{active:tab === 'overview'}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M0 1.75A.75.75 0 01.75 1h4.253c1.227 0 2.317.59 3 1.501A3.744 3.744 0 0111.006 1h4.245a.75.75 0 01.75.75v10.5a.75.75 0 01-.75.75h-4.507a2.25 2.25 0 00-1.591.659l-.622.621a.75.75 0 01-1.06 0l-.622-.621A2.25 2.25 0 005.258 13H.75a.75.75 0 01-.75-.75V1.75zm8.755 3a2.25 2.25 0 012.25-2.25H14.5v9h-3.757c-.71 0-1.4.201-1.992.572l.004-7.322zm-1.504 7.324l.004-5.073-.002-2.253A2.25 2.25 0 005.003 2.5H1.5v9h3.757a3.75 3.75 0 011.994.574z"></path></svg>
Overview
</div>
<div @click="tab = 'action'" class="tab" :class="{active:tab === 'action', disabled:!user}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M1.5 8a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0zM8 0a8 8 0 100 16A8 8 0 008 0zM6.379 5.227A.25.25 0 006 5.442v5.117a.25.25 0 00.379.214l4.264-2.559a.25.25 0 000-.428L6.379 5.227z"></path></svg>
Action
</div>
<div @click="tab = 'markdown'" class="tab" :class="{active:tab === 'markdown', disabled:!user}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M4 1.75C4 .784 4.784 0 5.75 0h5.586c.464 0 .909.184 1.237.513l2.914 2.914c.329.328.513.773.513 1.237v8.586A1.75 1.75 0 0114.25 15h-9a.75.75 0 010-1.5h9a.25.25 0 00.25-.25V6h-2.75A1.75 1.75 0 0110 4.25V1.5H5.75a.25.25 0 00-.25.25v2.5a.75.75 0 01-1.5 0v-2.5zm7.5-.188V4.25c0 .138.112.25.25.25h2.688a.252.252 0 00-.011-.013l-2.914-2.914a.272.272 0 00-.013-.011zM5.72 6.72a.75.75 0 000 1.06l1.47 1.47-1.47 1.47a.75.75 0 101.06 1.06l2-2a.75.75 0 000-1.06l-2-2a.75.75 0 00-1.06 0zM3.28 7.78a.75.75 0 00-1.06-1.06l-2 2a.75.75 0 000 1.06l2 2a.75.75 0 001.06-1.06L1.81 9.25l1.47-1.47z"></path></svg>
Markdown
</div>
</div>
</nav>
<!-- Body -->
<div class="body">
<div class="readme">
<svg viewBox="0 0 16 16" version="1.1" width="16" height="16" aria-hidden="true"><path fill-rule="evenodd" d="M1.326 1.973a1.2 1.2 0 011.49-.832c.387.112.977.307 1.575.602.586.291 1.243.71 1.7 1.296.022.027.042.056.061.084A13.22 13.22 0 018 3c.67 0 1.289.037 1.861.108l.051-.07c.457-.586 1.114-1.004 1.7-1.295a9.654 9.654 0 011.576-.602 1.2 1.2 0 011.49.832c.14.493.356 1.347.479 2.29.079.604.123 1.28.07 1.936.541.977.773 2.11.773 3.301C16 13 14.5 15 8 15s-8-2-8-5.5c0-1.034.238-2.128.795-3.117-.08-.712-.034-1.46.052-2.12.122-.943.34-1.797.479-2.29zM8 13.065c6 0 6.5-2 6-4.27C13.363 5.905 11.25 5 8 5s-5.363.904-6 3.796c-.5 2.27 0 4.27 6 4.27z"></path><path d="M4 8a1 1 0 012 0v1a1 1 0 01-2 0V8zm2.078 2.492c-.083-.264.146-.492.422-.492h3c.276 0 .505.228.422.492C9.67 11.304 8.834 12 8 12c-.834 0-1.669-.696-1.922-1.508zM10 8a1 1 0 112 0v1a1 1 0 11-2 0V8z"></path></svg>
<span>{{ user }}</span><span class="slash">/</span>README<span class="md">.md</span>
</div>
<!-- Overview -->
<section class="preview" v-if="tab == 'overview'">
Once generated, click on tabs above to see the code to embed them on your real readme !<br>
<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>
<!-- Markdown -->
<section v-else-if="tab == 'markdown'">
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>
</section>
<!-- Action -->
<section v-else-if="tab == 'action'">
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>
</section>
</div>
</div>
</section>
</template>
</main>
<!-- Scripts -->
@@ -307,8 +130,10 @@
<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/faker.min.js"></script>
<script src="/.js/vue.min.js"></script>
<script src="/.js/vue.prism.min.js"></script>
<script src="/.js/app.placeholder.js"></script>
<script src="/.js/app.js"></script>
</body>
</html>

View File

@@ -1,147 +1,159 @@
/* General */
body {
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
padding: 0;
margin: 0;
background-color: var(--color-bg-canvas);
}
main {
height: auto;
color: var(--color-text-primary);
background-color: var(--color-bg-canvas);
display: flex;
flex-direction: column;
}
.flex {
display: flex;
}
/* Title */
.title {
margin: 0;
padding: 1rem 2rem;
body {
font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;
margin: 0;
padding: 0;
background-color: var(--color-bg-canvas);
color: var(--color-text-primary);
}
/* Header */
header {
display: flex;
align-items: center;
font-size: 1.6rem;
padding: 1rem;
color: var(--color-header-text);
background-color: var(--color-header-bg);
}
.title a {
color: #fff !important;
font-weight: normal;
}
.title svg {
margin-right: 2rem;
fill: #fff !important;
width: 2rem;
height: 2rem;
}
/* Tabs */
nav {
display: none;
border-bottom: 1px solid var(--color-border-secondary);
flex-shrink: 0;
overflow-x: auto;
}
nav .tab {
flex-shrink: 0;
display: flex;
align-items: center;
padding: 8px 16px;
font-size: 14px;
line-height: 30px;
color: var(--color-underlinenav-text-hover);
cursor: pointer;
}
nav .tab.active {
color: var(--color-underlinenav-text-active);
border-bottom: 2px solid #f9826c;
font-weight: 600;
}
nav .tab.disabled {
opacity: .5;
cursor: not-allowed;
pointer-events: none;
}
nav .tab svg {
header svg {
fill: currentColor;
margin-right: .5rem;
margin-right: 1rem;
height: 2rem;
width: 2rem;
}
nav .right {
header a, header a:visited {
color: inherit;
text-decoration: none;
font-weight: 600;
font-size: 1.5rem;
}
/* Interface */
.ui {
display: flex;
border: none;
flex-direction: column;
}
nav .left {
.ui > * {
margin: 1rem;
}
.ui.top {
border-bottom: 1px solid var(--color-border-secondary);
}
.ui.top > * {
margin: 0 1rem;
}
.ui.top aside {
display: none;
}
/* Readme container */
.container {
max-width: 1280px;
display: flex;
flex-grow: 1;
height: 100%;
flex-direction: column;
}
.left, .right {
margin: 0 8px 16px;
}
.left {
flex-shrink: 0;
margin: 0;
width: 100%;
min-width: 230px;
display: flex;
flex-direction: column;
}
.left .user {
flex-shrink: 0;
display: flex;
flex-direction: column;
}
.left .user input, .left .user button {
width: 100%;
margin: 4px 0;
}
.left .scrollable {
flex-grow: 1;
overflow: auto;
margin-bottom: 1rem;
}
.right {
flex-grow: 1;
border-radius: 6px;
border: 1px solid var(--color-border-primary);
width: auto;
}
.right .body {
margin: 24px;
}
/* Avatar */
.avatar {
.ui-avatar {
display: none;
justify-content: center;
margin-top: -20%;
z-index: 888;
}
.avatar div {
width: 50%;
padding-top: 50%;
width: 70%;
padding-top: 70%;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--color-avatar-border);
border: 1px solid var(--color-border-primary);
background-color: black;
background-size: cover;
margin: -3rem auto 1rem;
}
/* Tabs */
nav {
display: flex;
align-items: center;
padding-top: 1.5rem;
overflow: auto;
}
nav svg {
fill: currentColor;
margin-right: .5rem;
}
nav > div {
display: flex;
align-items: center;
flex-shrink: 0;
padding: .5rem 1rem;
color: var(--color-underlinenav-text-hover);
cursor: pointer;
}
nav > div.active {
color: var(--color-underlinenav-text-active);
border-bottom: 2px solid var(--color-underlinenav-border-active);
font-weight: 600;
}
nav > div.disabled {
opacity: .5;
cursor: not-allowed;
}
nav > div:hover {
color: var(--color-underlinenav-text-hover);
border-bottom: 2px solid var(--color-underlinenav-border-hover);
transition-duration: all .12s ease-out;
}
/* Configuration */
aside {
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.configuration {
display: flex;
flex-direction: column;
padding-top: 1rem;
margin: 1rem .5rem 0;
border-top: 1px solid var(--color-border-primary);
}
.option {
display: flex;
flex-direction: column;
}
/* Preview */
.preview {
border: 1px solid var(--color-border-primary);
border-radius: 6px;
padding: 1rem;
flex-grow: 1;
overflow: auto;
}
.preview .image {
overflow: auto;
}
.preview .image.pending {
opacity: .25;
}
.code pre {
border-radius: 6px;
}
/* Readme */
.readme {
display: flex;
align-items: center;
font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace;
font-size: 12px;
margin-bottom: 16px;
font-size: .75rem;
margin-bottom: 1rem;
color: var(--color-text-primary);
}
.readme svg {
fill: currentColor;
margin-right: 8px;
margin-right: .5rem;
}
.readme .slash {
padding: 0 2px;
@@ -149,168 +161,84 @@
.readme .md {
color: var(--color-text-tertiary);
}
/* Readme content */
.right section {
height: 87%;
overflow: auto;
}
/* Code */
.code {
overflow-x: auto;
width: 100%;
}
.code pre {
border-radius: 5px;
}
/* Plugins */
.plugins, .templates {
display: flex;
flex-direction: column;
}
.plugins label, .templates label {
margin: 0;
display: flex;
align-items: center;
}
.plugins label svg, .templates label svg {
fill: currentColor;
}
.options {
display: flex;
flex-direction: column;
}
.options-group {
display: flex;
flex-direction: column;
}
.options-group label {
margin: 0;
display: flex;
flex-direction: column;
}
.options-group h4 {
font-size: 1rem;
font-weight: 600;
margin: 0;
}
/* Step */
.step {
padding: 1rem .5rem;
border-bottom: 1px solid var(--color-border-secondary);
}
.step h2 {
margin: 0;
margin-bottom: .25rem;
font-weight: 600;
font-size: 1.2rem;
}
/* Links */
a, a:hover, a:visited {
color: var(--color-text-link);
text-decoration: none;
font-style: normal;
outline: none;
}
a:hover {
text-decoration: underline;
transition: color .4s;
/* Inputs */
label {
cursor: pointer;
}
/* Inputs */
input, button, select {
label:hover {
background-color: var(--color-input-contrast-bg);
padding: 5px 12px;
font-size: 14px;
line-height: 20px;
border-radius: 6px;
}
input[type=text], input[type=number], select {
background-color: var(--color-input-contrast-bg);
padding: .4rem .8rem;
color: var(--color-text-primary);
background-color: var(--color-input-bg);
border: 1px solid var(--color-input-border);
border-radius: 6px;
outline: none;
box-shadow: var(--color-shadow-inset);
cursor: pointer;
}
input[type=text]:focus, input[type=number]:focus, select:focus {
background-color: var(--color-input-bg);
border-color: var(--color-state-focus-border);
outline: none;
box-shadow: var(--color-state-focus-shadow);
}
button {
color: var(--color-btn-primary-text);
background-color: var(--color-btn-primary-bg);
border-color: var(--color-btn-primary-border);
box-shadow: var(--color-btn-primary-shadow),var(--color-btn-primary-inset-shadow);
padding: .4rem .8rem;
border-radius: 6px;
font-weight: 500;
margin: .5rem 0;
cursor: pointer;
transition-duration: all .12s ease-out;
}
input:focus {
button[disabled] {
color: var(--color-btn-primary-disabled-text);
background-color: var(--color-btn-primary-disabled-bg);
border-color: var(--color-btn-primary-disabled-border);
}
button:focus {
outline: none;
}
label, button {
margin: 1rem;
/* Links */
a, a:hover, a:visited {
color: var(--color-text-link);
text-decoration: none;
}
label {
padding-right: .25rem;
padding-bottom: .125rem;
a:hover {
text-decoration: underline;
}
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;
}
.not-available {
opacity: .3;
}
/* 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 {
font-size: .8rem;
}
/* Metrics */
.metrics {
width: 100%;
max-width: 480px;
/* Requests */
small {
text-align: center;
}
/* Media screen */
@media only screen and (min-width: 700px) {
.left, .right {
height: 75%;
width: 0%;
}
.left {
width: 25%;
margin: 0 8px;
}
nav {
margin: 32px 0 24px;
overflow: hidden;
display: flex;
}
nav .left {
display: block;
}
nav .right {
height: 100%;
}
.container {
@media only screen and (min-width: 740px) {
.ui {
flex-direction: row;
}
main {
height: 100vh;
width: 100vw;
overflow: hidden;
.ui.top aside {
display: block;
}
.avatar {
display: flex;
.ui-avatar {
display: block;
}
.mobile {
display: none;
aside {
max-width: 25%;
}
}

View File

@@ -24,7 +24,7 @@
.filter(({actor}) => actor.login === login)
.filter(({created_at}) => Number.isFinite(days) ? new Date(created_at) > new Date(Date.now()-days*24*60*60*1000) : true)
.map(({type, payload, repo:{name:repo}}) => {
//See https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/github-event-types#memberevent
//See https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/github-event-types
switch (type) {
//Commented on a commit
case "CommitCommentEvent":{

View File

@@ -40,7 +40,7 @@
}
//Convert avatars to base64
console.debug(`metrics/compute/${login}/plugins > people > loading avatars`)
await Promise.all(result[type].map(async user => user.avatar = await imports.imgb64(user.avatarUrl)))
await Promise.all(result[type].map(async user => user.avatar = user.avatarUrl ? await imports.imgb64(user.avatarUrl) : "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg=="))
}
//Results

View File

@@ -6,7 +6,7 @@
<foreignObject x="0" y="0" width="100%" height="100%">
<div xmlns="http://www.w3.org/1999/xhtml" xmlns:xlink="http://www.w3.org/1999/xlink">
<% for (const partial of [...partials]) { %>
<%- await include(`partials/${partial}.ejs`); %>
<%- await include(`partials/${partial}.ejs`) %>
<% } %>
<% if (base.metadata) { %>

Before

Width:  |  Height:  |  Size: 978 B

After

Width:  |  Height:  |  Size: 977 B

View File

@@ -3,7 +3,7 @@
<section>
<h2 class="field">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16"><path fill-rule="evenodd" d="M5.5 3.5a2 2 0 100 4 2 2 0 000-4zM2 5.5a3.5 3.5 0 115.898 2.549 5.507 5.507 0 013.034 4.084.75.75 0 11-1.482.235 4.001 4.001 0 00-7.9 0 .75.75 0 01-1.482-.236A5.507 5.507 0 013.102 8.05 3.49 3.49 0 012 5.5zM11 4a.75.75 0 100 1.5 1.5 1.5 0 01.666 2.844.75.75 0 00-.416.672v.352a.75.75 0 00.574.73c1.2.289 2.162 1.2 2.522 2.372a.75.75 0 101.434-.44 5.01 5.01 0 00-2.56-3.012A3 3 0 0011 4z"></path></svg>
People
</h2>
<div class="row">
<section>
@@ -29,7 +29,7 @@
<%= plugins.people.error.message %>
</div>
<% } else { %>
<% for (const user of plugins.people.followers) { %><img class="avatar" src="data:image/png;base64,<%= user.avatar %>" width="<%= plugins.people.size %>" height="<%= plugins.people.size %>" alt=""/><% } %>
<% for (const user of plugins.people.followers) { %><img class="avatar" src="data:image/png;base64,<%= user.avatar %>" width="<%= plugins.people.size %>" height="<%= plugins.people.size %>" alt="" /><% } %>
<% } %>
</section>
</div>
@@ -49,7 +49,7 @@
<%= plugins.people.error.message %>
</div>
<% } else { %>
<% for (const user of plugins.people.following) { %><img class="avatar" src="data:image/png;base64,<%= user.avatar %>" width="<%= plugins.people.size %>" height="<%= plugins.people.size %>" alt=""/><% } %>
<% for (const user of plugins.people.following) { %><img class="avatar" src="data:image/png;base64,<%= user.avatar %>" width="<%= plugins.people.size %>" height="<%= plugins.people.size %>" alt="" /><% } %>
<% } %>
</section>
</div>

View File

@@ -20,7 +20,7 @@
</section>
<% } else { %>
<% for (const partial of [...partials]) { %>
<%- await include(`partials/${partial}.ejs`); %>
<%- await include(`partials/${partial}.ejs`) %>
<% } %>
<% } %>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -50,7 +50,7 @@ WARRANTY, to the extent permitted by applicable law.
Last generated: <%= new Date().toGMTString() %>
</div><% } -%>
<% for (const partial of [...partials]) { %><%- await include(`partials/${partial}.ejs`); %><% } -%>
<% for (const partial of [...partials]) { %><%- await include(`partials/${partial}.ejs`) %><% } -%>
<% if (base.metadata) { -%>
<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><%# -%>

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,7 +1,7 @@
<% if (base.header) { %>
<div class="stdin"><%- meta.$ %> whoami</div><%# -%>
<div class="stdout"><%# -%>
<b><%= user.name || user.login %></b> registered=<%= computed.registration.match(/^.+? [ym]/)[0].replace(/ /g, "") %>, uid=<%= `${user.databaseId}`.substr(-4) %>, gid=<%= user.organizations.totalCount %>
<b><%= user.name || user.login %></b> registered=<%= computed.registration.match(/^.+? [ymd]/)?.[0].replace(/ /g, "") %>, uid=<%= `${user.databaseId}`.substr(-4) %>, gid=<%= user.organizations.totalCount %>
contributed to <%= user.repositoriesContributedTo.totalCount %> repositor<%= s(user.repositoriesContributedTo.totalCount, "y") %> <b><% for (const [x, {color}] of Object.entries(computed.calendar)) { -%><span style="color:<%= color %>">#</span><% } %></b>
followed by <b><%= user.followers.totalCount %></b> user<%= s(user.followers.totalCount) %>
</div><% } -%>