@qiwi/tech-radar
Version:
Fully automated tech-radar generator
182 lines (161 loc) • 5.25 kB
JavaScript
import fse from 'fs-extra'
import path from 'node:path'
import { getSources, parse } from './parser/index.js'
import { render } from './renderer/index.js'
import { getDirs, tempDir } from './util.js'
/**
* @description
* Generate static sites from csv/json/yml radar declarations
*
* @func
* @param {Object} [options]
* @param {string} [options.input] globby pattern for input files (default `data/**\/*.{json,csv,yml}`)
* @param {string} [options.output] output directory (default `radar`)
* @param {string} [options.cwd] current working directory
* @param {string} [options.basePrefix] web app root level prefix; URL-shaped values (`https://…`, `//…`) become absolute, others relative
* @param {boolean} [options.autoscope] consider same-scoped files as subversions of a single radar
* @param {boolean} [options.navPage] generate navigation page
* @param {string} [options.navTitle] nav page title
* @param {string} [options.navFooter] nav page footer
* @param {string} [options.temp] temp directory
* @param {string} [options.templates] path to a directory whose contents are merged on top of bundled templates
* @param {Object} [options.renderSettings] custom render settings (rings, colors, dimensions) for `radar.js`
* @param {('zalando'|'aurora')} [options.renderer] output backend (default `zalando`)
* @param {string} [options.favicon] path to a custom favicon (`.ico`/`.png`) — copied to `<output>/favicon.ico`. If not provided, the bundled default is used.
* @param {string} [options.about] path to a .md or .html file with radar overview (aurora only)
* @param {boolean} [options.credits] include the generator credit in the legend footer (default `true`; aurora only)
*
* @return {Promise<void>}
*/
export const run = async (options) => {
const ctx = await getContext(options)
return readSources(ctx)
.then(parseRadars)
.then(sortRadars)
.then(resolveMoves)
.then(renderRadars)
.finally(() => cleanTemp(ctx))
}
const getContext = async ({
input = 'data/**/*.{json,csv,yml}',
output = 'radar',
cwd = process.cwd(),
basePrefix = '/',
autoscope = false,
navPage = false,
navTitle,
navFooter,
temp,
templates,
renderSettings,
renderer = 'zalando',
favicon,
about,
credits = true,
autoFitRings = false,
} = {}) => {
const ctx = {
input,
output: path.resolve(cwd, output),
cwd,
basePrefix,
autoscope,
navPage,
navTitle,
navFooter,
temp: temp || (await tempDir()),
templates,
renderSettings,
renderer,
favicon: favicon ? path.resolve(cwd, favicon) : undefined,
about: about ? path.resolve(cwd, about) : undefined,
credits,
autoFitRings,
}
ctx.ctx = ctx // context self-ref to simplify pipelining
return ctx
}
const readSources = async ({ ctx, cwd, input }) => {
ctx.sources = await getSources(input, cwd)
ctx.sources.sort()
ctx.scopes = getDirs(ctx.sources).map(path.dirname)
return ctx
}
const parseRadars = async ({ ctx, sources, scopes }) => {
ctx.radars = await Promise.all(
sources.map(async (file, i) => {
const document = await parse(file)
return {
document,
source: file,
scope: scopes[i],
date: document.meta.date,
title: document.meta.title,
}
}),
)
return ctx
}
const renderRadars = async ({ ctx }) => {
await render(ctx)
return ctx
}
const RING_WEIGHT = {
hold: 0,
assess: 1,
trial: 2,
adopt: 3,
}
/**
* Ring weight for the auto-trail computation. Inner ring = highest weight
* (most "adopted"). For legacy 4×4 radars uses the hardcoded RING_WEIGHT
* map; for Flex radars derives the weight from the radar's own ordered
* `rings` array (index 0 = innermost = N-1, last = outermost = 0).
*/
const getRingWeight = (ring, ringList) => {
if (ringList?.length) {
const idx = ringList.findIndex((r) => r.id === ring)
if (idx !== -1) return ringList.length - 1 - idx
}
return RING_WEIGHT[String(ring).toLowerCase()]
}
const resolveMoves = async ({ ctx, radars, autoscope }) => {
if (!autoscope) {
return ctx
}
radars.forEach(({ document: { data, rings } }, i) => {
const { scope } = radars[i]
data.forEach((entry) => {
const { name, ring } = entry
const lowerName = name.toLowerCase()
const prevRadar = radars[i + 1] // NOTE sorted by desc date
const prevEntry =
prevRadar &&
prevRadar.scope === scope &&
prevRadar.document.data.find(
({ name: _name }) => _name.toLowerCase() === lowerName,
)
// Comparing two snapshots that disagree on their ring shape is a
// best-effort: each side uses its own `rings` for weighting.
const prevRings = prevRadar?.document?.rings
entry.moved = prevEntry
? Math.sign(
getRingWeight(ring, rings) - getRingWeight(prevEntry.ring, prevRings),
)
: 0
})
})
return ctx
}
const sortRadars = async ({ ctx, radars }) => {
radars.sort(
(a, b) =>
path.dirname(a.source).localeCompare(path.dirname(b.source)) ||
Date.parse(b.date) - Date.parse(a.date),
)
return ctx
}
const cleanTemp = async ({ ctx, temp }) => {
await fse.remove(temp)
return ctx
}