import * as sass from "sass"
import {
escapeHtmlAttr,
expandTilde,
firstFound,
generateRandomId,
getCleanPath,
getHref,
slugifyString,
writeFile,
} from "./util/index.js"
import { glob } from "glob"
import fs from "fs/promises"
import handlebars from "handlebars"
import { marked } from "marked"
import markedCodePreview from "marked-code-preview"
import matter from "gray-matter"
import { optimize } from "svgo"
import sharp from "sharp"
import path from "path"
import { minify } from "html-minifier-terser"
import favicons from "favicons"
import _ from "lodash-es"
const templateCache = new Map()
const partialCache = new Map()
const registeredPartials = new Set()
export function clearTemplateCache() {
templateCache.clear()
partialCache.clear()
registeredPartials.clear()
}
async function loadPartials(partialDirs) {
if (!partialDirs || partialDirs.length === 0) {
return []
}
const loadedPaths = []
for (const dir of partialDirs) {
const expandedDir = expandTilde(dir)
const pattern = path.join(expandedDir, "*.hbs")
let files
try {
files = await glob(pattern)
} catch {
continue
}
for (const filePath of files) {
const fileName = path.basename(filePath, ".hbs")
// Remove leading underscore if present (e.g., _pagination -> pagination)
const partialName = fileName.startsWith("_") ? fileName.slice(1) : fileName
if (registeredPartials.has(partialName)) {
continue
}
if (!partialCache.has(filePath)) {
const content = await fs.readFile(filePath, "utf8")
partialCache.set(filePath, { name: partialName, content })
}
const partial = partialCache.get(filePath)
handlebars.registerPartial(partial.name, partial.content)
registeredPartials.add(partialName)
loadedPaths.push(filePath)
}
}
return loadedPaths
}
function createMarkdownRenderer(meta) {
return marked
.use({ gfm: true })
.use(markedCodePreview)
.use({
renderer: {
image({ href, title, text }) {
const attrs = [`alt="${escapeHtmlAttr(text)}"`]
const foundSrcSet = meta.resources.images?.[slugifyString(href)]
if (foundSrcSet && foundSrcSet.detail.srcSet?.length > 0) {
const srcSetString = foundSrcSet.detail.srcSet
.map(src => src.join(" "))
.join(", ")
const defaultSrc = foundSrcSet.detail.srcSet[0][0]
attrs.push(`src="${escapeHtmlAttr(defaultSrc)}"`)
attrs.push(`srcset="${escapeHtmlAttr(srcSetString)}"`)
attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
attrs.push(
`style="aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}"`,
)
} else {
attrs.push(`src="${escapeHtmlAttr(href)}"`)
}
if (title) {
attrs.push(`title="${escapeHtmlAttr(title)}"`)
}
return `
`
},
},
})
}
async function findTemplatePath(templateDirs, templateName) {
const templatePath = await firstFound(templateDirs, `${templateName}.hbs`)
if (!templatePath) throw new Error(`Template not found: ${templateName}`)
return templatePath
}
async function getTemplate(templatePath) {
if (!templateCache.has(templatePath)) {
const templateContent = await fs.readFile(templatePath, "utf8")
templateCache.set(templatePath, {
path: templatePath,
renderer: handlebars.compile(templateContent),
})
}
return templateCache.get(templatePath)
}
export async function renderTemplate({ config, meta }) {
const templateName = config.template || config.defaultTemplate
const templatePath =
config.filePath ||
(await findTemplatePath(config.templateDirs, templateName))
const fileOutputPath = config.fileOutputPath
const href = getHref(fileOutputPath, meta)
// Load partials from configured directories
const partialPaths = await loadPartials(config.partialDirs)
const template = await getTemplate(templatePath)
const html = template.renderer({
...meta,
href,
...config,
})
if (config.writeOut) {
const minifiedHtml = await minify(html, {
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeEmptyAttributes: true,
minifyCSS: true,
minifyJS: true,
})
await writeFile(fileOutputPath, minifiedHtml)
return {
detail: { html },
deps: {
paths: [template.path, ...partialPaths],
},
paths: [fileOutputPath],
ref: slugifyString(href),
}
}
return {
detail: { html },
deps: {
paths: [template.path, ...partialPaths],
},
ref: slugifyString(href),
}
}
export async function renderMarkdownToHtml({ config, meta }) {
const filePath = config.filePath
const fileOutputPath = config.fileOutputPath
const content = await fs.readFile(filePath, "utf8")
const { data, content: markdown } = matter(content)
const href = getHref(fileOutputPath, meta)
const renderer = createMarkdownRenderer(meta)
const html = renderer(markdown)
const detail = { ...data, href, content: html, fileOutputPath }
return {
detail,
ref: slugifyString(filePath),
}
}
export async function renderMarkdownWithTemplate({ config, meta }) {
const filePath = config.filePath
const fileOutputPath = config.fileOutputPath
const content = await fs.readFile(filePath, "utf8")
const { data, content: markdown } = matter(content)
const templateName = data.template || config.defaultTemplate
const href = getHref(fileOutputPath, meta)
if (!templateCache.has(templateName)) {
const templatePath = await firstFound(
config.templateDirs,
`${templateName}.hbs`,
)
if (!templatePath) throw new Error(`Template not found: ${templateName}`)
const templateContent = await fs.readFile(templatePath, "utf8")
templateCache.set(templateName, {
path: templatePath,
renderer: handlebars.compile(templateContent),
})
}
const template = templateCache.get(templateName)
const renderer = createMarkdownRenderer(meta)
const html = template.renderer({
...data,
...meta,
href,
content: renderer(markdown),
})
const minifiedHtml = await minify(html, {
collapseWhitespace: true,
removeComments: true,
removeRedundantAttributes: true,
removeEmptyAttributes: true,
minifyCSS: true,
minifyJS: true,
})
await writeFile(fileOutputPath, minifiedHtml)
return {
detail: { ...data, href },
paths: [fileOutputPath],
deps: {
paths: [template.path],
},
ref: slugifyString(fileOutputPath),
}
}
export async function compileSass({ config, meta }) {
const filePath = config.filePath
const fileOutputPath = config.fileOutputPath
const result = await sass.compileAsync(filePath, { style: "compressed" })
await writeFile(fileOutputPath, result.css)
return {
paths: [fileOutputPath],
ref: slugifyString(fileOutputPath),
detail: {
href: fileOutputPath.replace(meta.opts.outDir, "/"),
},
deps: {
paths: [...result.loadedUrls.map(item => item.pathname)],
},
}
}
export async function optimiseSvg({ config }) {
const filePath = config.filePath
const fileOutputPath = config.fileOutputPath
const svgString = await fs.readFile(filePath, "utf8")
const result = optimize(svgString, {
plugins: ["preset-default"],
})
await writeFile(fileOutputPath, result.data)
return {
paths: [fileOutputPath],
ref: slugifyString(fileOutputPath),
}
}
export async function copy({ config }) {
const filePath = config.filePath
const fileOutputPath = config.fileOutputPath
await fs.mkdir(config.fileOutputDir, { recursive: true })
await fs.copyFile(filePath, fileOutputPath)
return {
paths: [fileOutputPath],
ref: slugifyString(fileOutputPath),
}
}
export async function imageToWebP({ meta, config }) {
const filePath = config.filePath
const fileOutputDir = config.fileOutputDir
const sourceExtension = path.extname(filePath)
const outputExtension = config.outputFileExtension
const base = path.basename(filePath, sourceExtension)
await fs.mkdir(fileOutputDir, { recursive: true })
const original = sharp(filePath)
const metadata = await original.metadata()
const { width, height } = metadata
if (!width || !height) {
throw new Error("Could not determine image dimensions")
}
const aspectRatio = width / height
const name = config.uniqueFilenames ? `${base}-${generateRandomId()}` : base
const imageSizes = Array.isArray(config.imageSizes) ? config.imageSizes : []
if (imageSizes.length === 0) {
throw new Error(`imageToWebP: imageSizes must be a non-empty array for ${filePath}`)
}
const outputFiles = []
const srcSet = await Promise.all(
imageSizes.map(async size => {
const sizeStr = typeof size === "string" ? size : String(size)
const sizeNum = parseInt(sizeStr.replace("w", ""), 10)
const outputFile = path.join(
fileOutputDir,
`${name}-${sizeNum}${outputExtension}`,
)
await original
.clone()
.resize(sizeNum)
.webp({ quality: config.quality })
.toFile(outputFile)
outputFiles.push(outputFile)
return [getCleanPath(outputFile, meta), size]
}),
)
const imageRef = slugifyString(getCleanPath(path.join(filePath), meta))
return {
paths: outputFiles,
detail: { srcSet, aspectRatio },
ref: imageRef,
}
}
export async function generateFavicons({ meta, config }) {
const filePath = config.filePath
const fileOutputDir = config.fileOutputDir
// Configuration for favicons package
const configuration = {
path: getCleanPath(fileOutputDir, meta), // Path for overriding default icons path
appName: meta.opts.site?.name || "Website",
appShortName: meta.opts.site?.shortName || "Site",
appDescription: meta.opts.site?.description || "",
developerName: meta.opts.site?.author || "",
developerURL: meta.opts.site?.url || "",
dir: "auto",
lang: meta.opts.site?.language || "en-US",
background: meta.opts.site?.backgroundColor || "#ffffff",
theme_color: meta.opts.site?.themeColor || "#ffffff",
appleStatusBarStyle: "black-translucent",
display: "standalone",
orientation: "any",
scope: "/",
start_url: "/",
version: "1.0",
logging: false,
pixel_art: false,
loadManifestWithCredentials: false,
manifestMaskable: false,
icons: {
android: true,
appleIcon: true,
appleStartup: true,
favicons: true,
windows: true,
yandex: true,
},
}
try {
const response = await favicons(filePath, configuration)
// Write all generated images to disk
await Promise.all(
response.images.map(async image => {
const outputPath = path.join(fileOutputDir, image.name)
await writeFile(outputPath, image.contents)
}),
)
// Write all generated files (manifests, etc.) to disk
await Promise.all(
response.files.map(async file => {
const outputPath = path.join(fileOutputDir, file.name)
await writeFile(outputPath, file.contents)
}),
)
// Combine HTML meta tags
const htmlMeta = response.html.join("\n ")
return {
detail: {
htmlMeta,
},
paths: [
...response.images.map(img => path.join(fileOutputDir, img.name)),
...response.files.map(file => path.join(fileOutputDir, file.name)),
],
ref: config.name,
}
} catch (error) {
throw new Error(`Failed to generate favicons: ${error.message}`)
}
}
export async function generateTaxonomy({ config }) {
if (!Array.isArray(config.inputs)) {
return {
detail: config.indexOn ? {} : [],
ref: config.name,
}
}
const allValues = config.inputs.reduce((values, curr) => {
const items = curr[config.indexOn] || []
items.forEach(v => values.add(v))
return values
}, new Set())
const orderBy = config.orderBy || "date"
const sortedInputs = config.sortAscending
? _.sortBy(config.inputs, orderBy)
: _.sortBy(config.inputs, orderBy).reverse()
const taxonomy = config.indexOn
? [...allValues.values()].reduce((groups, currentGroup) => {
const grouped = {
...groups,
[currentGroup]: sortedInputs
.filter(item => item[config.indexOn].includes(currentGroup))
.map(item => {
const entry = config.properties
? config.properties.reduce(
(ent, prop) => ({ ...ent, [prop]: item[prop] }),
{},
)
: item
return entry
}),
}
return grouped
}, {})
: sortedInputs.map(item => {
const entry = config.properties
? config.properties.reduce(
(ent, prop) => ({ ...ent, [prop]: item[prop] }),
{},
)
: item
return entry
})
return {
detail: taxonomy,
ref: config.name,
}
}