@qiwi/tech-radar
Version:
Fully automated tech-radar generator
260 lines (242 loc) • 7.88 kB
JavaScript
import path from 'node:path'
import fse from 'fs-extra'
import { rootDir } from '../../constants.js'
import { js as clientJs } from './client.js'
import {
aboutPage,
entryPage,
entrySlug,
radarPage,
redirectPage,
} from './pages.js'
import { css } from './styles.js'
/** Minimal markdown → HTML for the About page. Handles h1/h2/h3, paragraphs,
* unordered lists, **bold**, and [text](url) inline. Anything fancier
* should be written as raw HTML. */
// Allow-list of URL schemes that can land in a markdown link's `href` —
// blocks `javascript:` / `data:` / etc., which would otherwise become
// executable code if an About-source author is careless or malicious.
const SAFE_URL = /^(https?:\/\/|mailto:|\/|#|\.{1,2}\/)/i
const mdToHtml = (md) => {
const inline = (s) =>
s
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replaceAll(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, url) => {
const safe = SAFE_URL.test(url.trim()) ? url.trim() : '#'
return `<a href="${safe}" target="_blank" rel="noopener">${text}</a>`
})
const lines = md.split(/\r?\n/)
const out = []
let para = []
let inList = false
const flushPara = () => {
if (para.length) {
out.push(`<p>${para.join(' ').trim()}</p>`)
para = []
}
}
const flushList = () => {
if (inList) {
out.push('</ul>')
inList = false
}
}
for (const raw of lines) {
const line = raw.trim()
if (!line) {
flushPara()
flushList()
continue
}
const h = line.match(/^(#{1,3})\s+(.+)$/)
if (h) {
flushPara()
flushList()
out.push(`<h${h[1].length}>${inline(h[2])}</h${h[1].length}>`)
continue
}
const li = line.match(/^[-*]\s+(.+)$/)
if (li) {
flushPara()
if (!inList) {
out.push('<ul>')
inList = true
}
out.push(`<li>${inline(li[1])}</li>`)
continue
}
para.push(inline(line))
}
flushPara()
flushList()
return out.join('\n')
}
/** Load the about-content file referenced by `ctx.about`. `.md` is parsed
* through the minimal MD converter; `.html` is embedded as-is. */
const loadAbout = async (filePath) => {
if (!filePath) return null
if (!(await fse.pathExists(filePath))) return null
const raw = await fse.readFile(filePath, 'utf8')
const ext = path.extname(filePath).toLowerCase()
return ext === '.md' ? mdToHtml(raw) : raw
}
/** Bundled favicon used when `ctx.favicon` is not provided. Lives inside
* the zalando templates dir so the package only ships one copy. */
const DEFAULT_FAVICON = path.resolve(
rootDir,
'renderer/zalando/templates/assets/favicon.ico',
)
/** Build per-scope timeline (sorted desc by date) from the full radar list. */
const buildTimelines = (radars) => {
const byScope = new Map()
for (const r of radars) {
if (!byScope.has(r.scope)) byScope.set(r.scope, [])
byScope.get(r.scope).push({ date: r.date, title: r.title })
}
for (const list of byScope.values()) {
list.sort((a, b) => Date.parse(b.date) - Date.parse(a.date))
}
return byScope
}
/** "/foo/bar/" segment count → "../" prefix that climbs back to dist root. */
const upToRoot = (depth) => '../'.repeat(depth)
/**
* Aurora — pure-SVG static renderer (alternative to the Zalando-style backend).
* Each radar page hosts the scope-switcher and timeline, so there is no
* separate root index — `dist/index.html` is just a redirect to the
* default scope's latest snapshot.
*
* @param {Object} ctx
* @returns {Promise<void>}
*/
export const render = async (ctx) => {
const {
radars,
output,
navTitle,
navFooter,
about,
credits = true,
favicon,
autoFitRings = false,
} = ctx
if (!radars || radars.length === 0) return
const timelines = buildTimelines(radars)
const scopes = [...new Set(radars.map((r) => r.scope))]
.filter((s) => s !== '.')
.toSorted()
// Map: scope → latest snapshot date. Used by scope-tabs to deep-link.
const scopeLatest = Object.fromEntries(
scopes.map((s) => [s, timelines.get(s)?.[0]?.date]),
)
// About page (optional). When the source file resolves to non-empty HTML
// we render `<output>/about/index.html` and pass `aboutHref` to every
// page so they can surface the `?` link in the topbar.
const aboutHtml = await loadAbout(about)
const hasAbout = !!aboutHtml
// --- Per-radar pages -----------------------------------------------------
await Promise.all(
radars.map(async (radar) => {
const { scope, date } = radar
const radarDir = path.join(output, scope, date)
// Depth from dist root: <scope>/<date>/ → 2 segments
const basePath = upToRoot(2)
// Main radar page
await fse.outputFile(
path.join(radarDir, 'index.html'),
radarPage({
radar,
scope,
scopes,
scopeLatest,
date,
timeline: timelines.get(scope) || [],
basePath,
navTitle,
navFooter,
credits,
aboutHref: hasAbout ? `${basePath}about/` : null,
autoFitRings,
}),
)
// Per-entry detail pages (depth: <scope>/<date>/entries/<sId>/<slug>/ → 5).
// Legacy 4×4 radars carry both `quadrant` (q1..q4) and `sector` (s1..s4);
// we prefer the legacy id so existing deployment URLs stay stable. Flex
// radars only have `sector` (s1..sN).
const entryBasePath = upToRoot(5)
const radarPath = upToRoot(3)
await Promise.all(
radar.document.data.map(async (entry) => {
const slug = entrySlug(entry.name)
const sectorDir = entry.quadrant ?? entry.sector
const dir = path.join(radarDir, 'entries', sectorDir, slug)
await fse.outputFile(
path.join(dir, 'index.html'),
entryPage({
entry,
radar,
scope,
date,
basePath: entryBasePath,
radarPath,
navTitle,
}),
)
}),
)
}),
)
// --- About page (single global) -----------------------------------------
if (hasAbout) {
await fse.outputFile(
path.join(output, 'about', 'index.html'),
aboutPage({
contentHtml: aboutHtml,
basePath: upToRoot(1), // about/ → dist root
navTitle,
}),
)
}
// --- Per-scope redirect → latest snapshot --------------------------------
await Promise.all(
scopes.map(async (scope) => {
const tl = timelines.get(scope)
if (!tl?.length) return
await fse.outputFile(
path.join(output, scope, 'index.html'),
redirectPage({ date: tl[0].date }),
)
}),
)
// --- Root → first scope's latest snapshot --------------------------------
// Two hops: root → /<scope>/ → /<scope>/<date>/. Single redirect file is enough.
if (scopes.length > 0) {
const defaultScope = scopes[0]
await fse.outputFile(
path.join(output, 'index.html'),
`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url=${defaultScope}/">
<link rel="canonical" href="${defaultScope}/">
<title>${navTitle ? navTitle.replace(/[<>"']/g, '') : 'Tech radar'}</title>
</head>
<body>
<a href="${defaultScope}/">Open ${defaultScope} radar</a>
</body>
</html>
`,
)
}
// --- Shared assets at dist root -----------------------------------------
await fse.outputFile(path.join(output, 'aurora.css'), css)
await fse.outputFile(path.join(output, 'aurora.js'), clientJs)
const faviconSrc = favicon || DEFAULT_FAVICON
if (await fse.pathExists(faviconSrc)) {
await fse.copy(faviconSrc, path.join(output, 'favicon.ico'))
}
}