| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451 |
- 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 `<img ${attrs.join(" ")} >`
- },
- },
- })
- }
- 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,
- }
- }
|