Files
metrics/source/app/metrics/metadata.mjs
2021-11-26 18:49:24 -05:00

332 lines
13 KiB
JavaScript

//Imports
import fs from "fs"
import yaml from "js-yaml"
import path from "path"
import url from "url"
//Defined categories
const categories = ["core", "github", "social", "community"]
/**Metadata descriptor parser */
export default async function metadata({log = true} = {}) {
//Paths
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../../..")
const __templates = path.join(__metrics, "source/templates")
const __plugins = path.join(__metrics, "source/plugins")
const __package = path.join(__metrics, "package.json")
const __descriptor = path.join(__metrics, "action.yml")
//Init
const logger = log ? console.debug : () => null
//Load plugins metadata
let Plugins = {}
logger("metrics/metadata > loading plugins metadata")
for (const name of await fs.promises.readdir(__plugins)) {
if (!(await fs.promises.lstat(path.join(__plugins, name))).isDirectory())
continue
logger(`metrics/metadata > loading plugin metadata [${name}]`)
Plugins[name] = await metadata.plugin({__plugins, name, logger})
}
//Reorder keys
const {base, core, ...plugins} = Plugins //eslint-disable-line no-unused-vars
Plugins = Object.fromEntries(Object.entries(Plugins).sort(([_an, a], [_bn, b]) => a.category === b.category ? (a.index ?? Infinity) - (b.index ?? Infinity) : categories.indexOf(a.category) - categories.indexOf(b.category)))
logger(`metrics/metadata > loaded [${Object.keys(Plugins).join(", ")}]`)
//Load templates metadata
let Templates = {}
logger("metrics/metadata > loading templates metadata")
for (const name of await fs.promises.readdir(__templates)) {
if (!(await fs.promises.lstat(path.join(__templates, name))).isDirectory())
continue
logger(`metrics/metadata > loading template metadata [${name}]`)
Templates[name] = await metadata.template({__templates, name, plugins, logger})
}
//Reorder keys
const {community, ...templates} = Templates
Templates = {...Object.fromEntries(Object.entries(templates).sort(([_an, a], [_bn, b]) => (a.index ?? Infinity) - (b.index ?? Infinity))), community}
//Packaged metadata
const packaged = JSON.parse(`${await fs.promises.readFile(__package)}`)
//Descriptor metadata
const descriptor = yaml.load(`${await fs.promises.readFile(__descriptor, "utf-8")}`)
//Metadata
return {plugins:Plugins, templates:Templates, packaged, descriptor}
}
/**Metadata extractor for templates */
metadata.plugin = async function({__plugins, name, logger}) {
try {
//Load meta descriptor
const raw = `${await fs.promises.readFile(path.join(__plugins, name, "metadata.yml"), "utf-8")}`
const {inputs, ...meta} = yaml.load(raw)
//category
if (!categories.includes(meta.category))
meta.category = "community"
//Inputs parser
{
meta.inputs = function({data:{user = null} = {}, q, account}, defaults = {}) {
//Support check
if (!account)
logger(`metrics/inputs > account type not set for plugin ${name}!`)
if (account !== "bypass") {
const context = q.repo ? "repository" : account
if (!meta.supports?.includes(context))
throw {error:{message:`Not supported for: ${context}`, instance:new Error()}}
}
//Special values replacer
const replacer = value => {
value = `${value}`.trim()
if (user) {
if (value === ".user.login")
return user.login ?? value
if (value === ".user.twitter")
return user.twitterUsername ?? value
if (value === ".user.website")
return user.websiteUrl ?? value
}
return value
}
//Inputs checks
const result = Object.fromEntries(
Object.entries(inputs).map(([key, {type, format, default:defaulted, min, max, values}]) => [
//Format key
metadata.to.query(key, {name}),
//Format value
(defaulted => {
//Default value
let value = q[metadata.to.query(key)] ?? q[key] ?? defaulted
//Apply type conversion
switch (type) {
//Booleans
case "boolean": {
if (/^(?:[Tt]rue|[Oo]n|[Yy]es|1)$/.test(value))
return true
if (/^(?:[Ff]alse|[Oo]ff|[Nn]o|0)$/.test(value))
return false
return defaulted
}
//Numbers
case "number": {
value = Number(value)
if (!Number.isFinite(value))
value = defaulted
if (Number.isFinite(min))
value = Math.max(min, value)
if (Number.isFinite(max))
value = Math.min(value, max)
return value
}
//Array
case "array": {
try {
value = decodeURIComponent(value)
}
catch {
logger(`metrics/inputs > failed to decode uri : ${value}`)
value = defaulted
}
const separators = {"comma-separated":",", "space-separated":" "}
const separator = separators[[format].flat().filter(s => s in separators)[0]] ?? ","
return value.split(separator).map(v => replacer(v).toLocaleLowerCase()).filter(v => Array.isArray(values) ? values.includes(v) : true).filter(v => v)
}
//String
case "string": {
value = replacer(value)
if ((Array.isArray(values)) && (!values.includes(value)))
return defaulted
return value
}
//JSON
case "json": {
if (typeof value === "object")
return value
try {
value = JSON.parse(value)
}
catch (error) {
try {
value = JSON.parse(decodeURIComponent(value))
}
catch (error) {
logger(`metrics/inputs > failed to parse json : ${value}`)
value = JSON.parse(defaulted)
}
}
return value
}
//Token
case "token": {
return value
}
//Default
default: {
return value
}
}
})(defaults[key] ?? defaulted),
]),
)
logger(`metrics/inputs > ${name} > ${JSON.stringify(result)}`)
return result
}
Object.assign(meta.inputs, inputs, Object.fromEntries(Object.entries(inputs).map(([key, value]) => [metadata.to.query(key, {name}), value])))
}
//Action metadata
{
//Extract comments
const comments = {}
raw.split(/(?:\r?\n){2,}/m)
.map(x => x.trim()).filter(x => x)
.map(x => x.split("\n").map(y => y.trim()).join("\n"))
.map(x => {
const input = x.match(new RegExp(`^\\s*(?<input>${Object.keys(inputs).join("|")}):`, "m"))?.groups?.input ?? null
if (input)
comments[input] = x.match(new RegExp(`(?<comment>[\\s\\S]*?)(?=(?:${Object.keys(inputs).sort((a, b) => b.length - a.length).join("|")}):)`))?.groups?.comment
})
//Action descriptor
meta.action = Object.fromEntries(
Object.entries(inputs).map(([key, value]) => [
key,
{
comment:comments[key] ?? `# ${value.description}`,
descriptor:yaml.dump({[key]:Object.fromEntries(Object.entries(value).filter(([key]) => ["description", "default", "required"].includes(key)))}, {quotingType:'"', noCompatMode:true}),
},
]),
)
//Action inputs
meta.inputs.action = function({core}) {
//Build query object from inputs
const q = {}
for (const key of Object.keys(inputs)) {
const value = `${core.getInput(key)}`.trim()
try {
q[key] = decodeURIComponent(value)
}
catch {
logger(`metrics/inputs > failed to decode uri : ${value}`)
q[key] = value
}
}
return meta.inputs({q, account:"bypass"})
}
}
//Web metadata
{
meta.web = Object.fromEntries(
Object.entries(inputs).map(([key, {type, description:text, example, default:defaulted, min = 0, max = 9999, values}]) => [
//Format key
metadata.to.query(key),
//Value descriptor
(() => {
switch (type) {
case "boolean":
return {text, type:"boolean", defaulted:/^(?:[Tt]rue|[Oo]n|[Yy]es|1)$/.test(defaulted) ? true : /^(?:[Ff]alse|[Oo]ff|[Nn]o|0)$/.test(defaulted) ? false : defaulted}
case "number":
return {text, type:"number", min, max, defaulted}
case "array":
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
case "string": {
if (Array.isArray(values))
return {text, type:"select", values, defaulted}
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
}
case "json":
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
default:
return null
}
})(),
]).filter(([key, value]) => (value) && (key !== name)),
)
}
//Readme metadata
{
//Extract demos
const raw = `${await fs.promises.readFile(path.join(__plugins, name, "README.md"), "utf-8")}`
const demo = raw.match(/(?<demo><table>[\s\S]*?<[/]table>)/)?.groups?.demo?.replace(/<[/]?(?:table|tr)>/g, "")?.trim() ?? "<td></td>"
//Readme descriptor
meta.readme = {demo}
}
//Icon
meta.icon = meta.name.split(" ")[0] ?? null
//Result
return meta
}
catch (error) {
logger(`metrics/metadata > failed to load plugin ${name}: ${error}`)
return null
}
}
/**Metadata extractor for templates */
metadata.template = async function({__templates, name, plugins, logger}) {
try {
//Load meta descriptor
const raw = fs.existsSync(path.join(__templates, name, "metadata.yml")) ? `${await fs.promises.readFile(path.join(__templates, name, "metadata.yml"), "utf-8")}` : ""
const readme = fs.existsSync(path.join(__templates, name, "README.md")) ? `${await fs.promises.readFile(path.join(__templates, name, "README.md"), "utf-8")}` : ""
const meta = yaml.load(raw) ?? {}
//Compatibility
const partials = path.join(__templates, name, "partials")
const compatibility = Object.fromEntries(Object.entries(plugins).map(([key]) => [key, false]))
if ((fs.existsSync(partials)) && ((await fs.promises.lstat(partials)).isDirectory())) {
for (let plugin of await fs.promises.readdir(partials)) {
plugin = plugin.match(/(?<plugin>^[\s\S]+(?=[.]ejs$))/)?.groups?.plugin ?? null
if (plugin in compatibility)
compatibility[plugin] = true
}
}
//Result
return {
name:meta.name ?? readme.match(/^### (?<name>[\s\S]+?)\n/)?.groups?.name?.trim(),
index:meta.index ?? null,
formats:meta.formats ?? null,
supports:meta.supports ?? null,
readme:{
demo:readme.match(/(?<demo><table>[\s\S]*?<[/]table>)/)?.groups?.demo?.replace(/<[/]?(?:table|tr)>/g, "")?.trim() ?? (name === "community" ? '<td align="center" colspan="2">See <a href="/source/templates/community/README.md">documentation</a> 🌍</td>' : "<td></td>"),
compatibility:{
...Object.fromEntries(Object.entries(compatibility).filter(([_, value]) => value)),
...Object.fromEntries(Object.entries(compatibility).filter(([_, value]) => !value).map(([key, value]) => [key, meta.formats?.includes("markdown") ? "embed" : value])),
base:true
},
},
check({q, account = "bypass", format = null}) {
//Support check
if (account !== "bypass") {
const context = q.repo ? "repository" : account
if ((Array.isArray(this.supports)) && (!this.supports.includes(context)))
throw new Error(`not supported for: ${context}`)
}
//Format check
if ((format) && (Array.isArray(this.formats)) && (!this.formats.includes(format)))
throw new Error(`not supported for: ${format}`)
},
}
}
catch (error) {
logger(`metrics/metadata > failed to load template ${name}: ${error}`)
return null
}
}
/**Metadata converters */
metadata.to = {
query(key, {name = null} = {}) {
key = key.replace(/^plugin_/, "").replace(/_/g, ".")
return name ? key.replace(new RegExp(`^(${name}.)`, "g"), "") : key
},
}