processors.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. import * as sass from "sass"
  2. import {
  3. escapeHtmlAttr,
  4. expandTilde,
  5. firstFound,
  6. generateRandomId,
  7. getCleanPath,
  8. getHref,
  9. slugifyString,
  10. writeFile,
  11. } from "./util/index.js"
  12. import { glob } from "glob"
  13. import fs from "fs/promises"
  14. import handlebars from "handlebars"
  15. import { marked } from "marked"
  16. import markedCodePreview from "marked-code-preview"
  17. import matter from "gray-matter"
  18. import { optimize } from "svgo"
  19. import sharp from "sharp"
  20. import path from "path"
  21. import { minify } from "html-minifier-terser"
  22. import favicons from "favicons"
  23. import _ from "lodash-es"
  24. const templateCache = new Map()
  25. const partialCache = new Map()
  26. const registeredPartials = new Set()
  27. export function clearTemplateCache() {
  28. templateCache.clear()
  29. partialCache.clear()
  30. registeredPartials.clear()
  31. }
  32. async function loadPartials(partialDirs) {
  33. if (!partialDirs || partialDirs.length === 0) {
  34. return []
  35. }
  36. const loadedPaths = []
  37. for (const dir of partialDirs) {
  38. const expandedDir = expandTilde(dir)
  39. const pattern = path.join(expandedDir, "*.hbs")
  40. let files
  41. try {
  42. files = await glob(pattern)
  43. } catch {
  44. continue
  45. }
  46. for (const filePath of files) {
  47. const fileName = path.basename(filePath, ".hbs")
  48. // Remove leading underscore if present (e.g., _pagination -> pagination)
  49. const partialName = fileName.startsWith("_") ? fileName.slice(1) : fileName
  50. if (registeredPartials.has(partialName)) {
  51. continue
  52. }
  53. if (!partialCache.has(filePath)) {
  54. const content = await fs.readFile(filePath, "utf8")
  55. partialCache.set(filePath, { name: partialName, content })
  56. }
  57. const partial = partialCache.get(filePath)
  58. handlebars.registerPartial(partial.name, partial.content)
  59. registeredPartials.add(partialName)
  60. loadedPaths.push(filePath)
  61. }
  62. }
  63. return loadedPaths
  64. }
  65. function createMarkdownRenderer(meta) {
  66. return marked
  67. .use({ gfm: true })
  68. .use(markedCodePreview)
  69. .use({
  70. renderer: {
  71. image({ href, title, text }) {
  72. const attrs = [`alt="${escapeHtmlAttr(text)}"`]
  73. const foundSrcSet = meta.resources.images?.[slugifyString(href)]
  74. if (foundSrcSet && foundSrcSet.detail.srcSet?.length > 0) {
  75. const srcSetString = foundSrcSet.detail.srcSet
  76. .map(src => src.join(" "))
  77. .join(", ")
  78. const defaultSrc = foundSrcSet.detail.srcSet[0][0]
  79. attrs.push(`src="${escapeHtmlAttr(defaultSrc)}"`)
  80. attrs.push(`srcset="${escapeHtmlAttr(srcSetString)}"`)
  81. attrs.push("sizes=\"(min-width: 800px) 40vw, 100vw\"")
  82. attrs.push(
  83. `style="aspect-ratio: ${escapeHtmlAttr(foundSrcSet.detail.aspectRatio)}"`,
  84. )
  85. } else {
  86. attrs.push(`src="${escapeHtmlAttr(href)}"`)
  87. }
  88. if (title) {
  89. attrs.push(`title="${escapeHtmlAttr(title)}"`)
  90. }
  91. return `<img ${attrs.join(" ")} >`
  92. },
  93. },
  94. })
  95. }
  96. async function findTemplatePath(templateDirs, templateName) {
  97. const templatePath = await firstFound(templateDirs, `${templateName}.hbs`)
  98. if (!templatePath) throw new Error(`Template not found: ${templateName}`)
  99. return templatePath
  100. }
  101. async function getTemplate(templatePath) {
  102. if (!templateCache.has(templatePath)) {
  103. const templateContent = await fs.readFile(templatePath, "utf8")
  104. templateCache.set(templatePath, {
  105. path: templatePath,
  106. renderer: handlebars.compile(templateContent),
  107. })
  108. }
  109. return templateCache.get(templatePath)
  110. }
  111. export async function renderTemplate({ config, meta }) {
  112. const templateName = config.template || config.defaultTemplate
  113. const templatePath =
  114. config.filePath ||
  115. (await findTemplatePath(config.templateDirs, templateName))
  116. const fileOutputPath = config.fileOutputPath
  117. const href = getHref(fileOutputPath, meta)
  118. // Load partials from configured directories
  119. const partialPaths = await loadPartials(config.partialDirs)
  120. const template = await getTemplate(templatePath)
  121. const html = template.renderer({
  122. ...meta,
  123. href,
  124. ...config,
  125. })
  126. if (config.writeOut) {
  127. const minifiedHtml = await minify(html, {
  128. collapseWhitespace: true,
  129. removeComments: true,
  130. removeRedundantAttributes: true,
  131. removeEmptyAttributes: true,
  132. minifyCSS: true,
  133. minifyJS: true,
  134. })
  135. await writeFile(fileOutputPath, minifiedHtml)
  136. return {
  137. detail: { html },
  138. deps: {
  139. paths: [template.path, ...partialPaths],
  140. },
  141. paths: [fileOutputPath],
  142. ref: slugifyString(href),
  143. }
  144. }
  145. return {
  146. detail: { html },
  147. deps: {
  148. paths: [template.path, ...partialPaths],
  149. },
  150. ref: slugifyString(href),
  151. }
  152. }
  153. export async function renderMarkdownToHtml({ config, meta }) {
  154. const filePath = config.filePath
  155. const fileOutputPath = config.fileOutputPath
  156. const content = await fs.readFile(filePath, "utf8")
  157. const { data, content: markdown } = matter(content)
  158. const href = getHref(fileOutputPath, meta)
  159. const renderer = createMarkdownRenderer(meta)
  160. const html = renderer(markdown)
  161. const detail = { ...data, href, content: html, fileOutputPath }
  162. return {
  163. detail,
  164. ref: slugifyString(filePath),
  165. }
  166. }
  167. export async function renderMarkdownWithTemplate({ config, meta }) {
  168. const filePath = config.filePath
  169. const fileOutputPath = config.fileOutputPath
  170. const content = await fs.readFile(filePath, "utf8")
  171. const { data, content: markdown } = matter(content)
  172. const templateName = data.template || config.defaultTemplate
  173. const href = getHref(fileOutputPath, meta)
  174. if (!templateCache.has(templateName)) {
  175. const templatePath = await firstFound(
  176. config.templateDirs,
  177. `${templateName}.hbs`,
  178. )
  179. if (!templatePath) throw new Error(`Template not found: ${templateName}`)
  180. const templateContent = await fs.readFile(templatePath, "utf8")
  181. templateCache.set(templateName, {
  182. path: templatePath,
  183. renderer: handlebars.compile(templateContent),
  184. })
  185. }
  186. const template = templateCache.get(templateName)
  187. const renderer = createMarkdownRenderer(meta)
  188. const html = template.renderer({
  189. ...data,
  190. ...meta,
  191. href,
  192. content: renderer(markdown),
  193. })
  194. const minifiedHtml = await minify(html, {
  195. collapseWhitespace: true,
  196. removeComments: true,
  197. removeRedundantAttributes: true,
  198. removeEmptyAttributes: true,
  199. minifyCSS: true,
  200. minifyJS: true,
  201. })
  202. await writeFile(fileOutputPath, minifiedHtml)
  203. return {
  204. detail: { ...data, href },
  205. paths: [fileOutputPath],
  206. deps: {
  207. paths: [template.path],
  208. },
  209. ref: slugifyString(fileOutputPath),
  210. }
  211. }
  212. export async function compileSass({ config, meta }) {
  213. const filePath = config.filePath
  214. const fileOutputPath = config.fileOutputPath
  215. const result = await sass.compileAsync(filePath, { style: "compressed" })
  216. await writeFile(fileOutputPath, result.css)
  217. return {
  218. paths: [fileOutputPath],
  219. ref: slugifyString(fileOutputPath),
  220. detail: {
  221. href: fileOutputPath.replace(meta.opts.outDir, "/"),
  222. },
  223. deps: {
  224. paths: [...result.loadedUrls.map(item => item.pathname)],
  225. },
  226. }
  227. }
  228. export async function optimiseSvg({ config }) {
  229. const filePath = config.filePath
  230. const fileOutputPath = config.fileOutputPath
  231. const svgString = await fs.readFile(filePath, "utf8")
  232. const result = optimize(svgString, {
  233. plugins: ["preset-default"],
  234. })
  235. await writeFile(fileOutputPath, result.data)
  236. return {
  237. paths: [fileOutputPath],
  238. ref: slugifyString(fileOutputPath),
  239. }
  240. }
  241. export async function copy({ config }) {
  242. const filePath = config.filePath
  243. const fileOutputPath = config.fileOutputPath
  244. await fs.mkdir(config.fileOutputDir, { recursive: true })
  245. await fs.copyFile(filePath, fileOutputPath)
  246. return {
  247. paths: [fileOutputPath],
  248. ref: slugifyString(fileOutputPath),
  249. }
  250. }
  251. export async function imageToWebP({ meta, config }) {
  252. const filePath = config.filePath
  253. const fileOutputDir = config.fileOutputDir
  254. const sourceExtension = path.extname(filePath)
  255. const outputExtension = config.outputFileExtension
  256. const base = path.basename(filePath, sourceExtension)
  257. await fs.mkdir(fileOutputDir, { recursive: true })
  258. const original = sharp(filePath)
  259. const metadata = await original.metadata()
  260. const { width, height } = metadata
  261. if (!width || !height) {
  262. throw new Error("Could not determine image dimensions")
  263. }
  264. const aspectRatio = width / height
  265. const name = config.uniqueFilenames ? `${base}-${generateRandomId()}` : base
  266. const imageSizes = Array.isArray(config.imageSizes) ? config.imageSizes : []
  267. if (imageSizes.length === 0) {
  268. throw new Error(`imageToWebP: imageSizes must be a non-empty array for ${filePath}`)
  269. }
  270. const outputFiles = []
  271. const srcSet = await Promise.all(
  272. imageSizes.map(async size => {
  273. const sizeStr = typeof size === "string" ? size : String(size)
  274. const sizeNum = parseInt(sizeStr.replace("w", ""), 10)
  275. const outputFile = path.join(
  276. fileOutputDir,
  277. `${name}-${sizeNum}${outputExtension}`,
  278. )
  279. await original
  280. .clone()
  281. .resize(sizeNum)
  282. .webp({ quality: config.quality })
  283. .toFile(outputFile)
  284. outputFiles.push(outputFile)
  285. return [getCleanPath(outputFile, meta), size]
  286. }),
  287. )
  288. const imageRef = slugifyString(getCleanPath(path.join(filePath), meta))
  289. return {
  290. paths: outputFiles,
  291. detail: { srcSet, aspectRatio },
  292. ref: imageRef,
  293. }
  294. }
  295. export async function generateFavicons({ meta, config }) {
  296. const filePath = config.filePath
  297. const fileOutputDir = config.fileOutputDir
  298. // Configuration for favicons package
  299. const configuration = {
  300. path: getCleanPath(fileOutputDir, meta), // Path for overriding default icons path
  301. appName: meta.opts.site?.name || "Website",
  302. appShortName: meta.opts.site?.shortName || "Site",
  303. appDescription: meta.opts.site?.description || "",
  304. developerName: meta.opts.site?.author || "",
  305. developerURL: meta.opts.site?.url || "",
  306. dir: "auto",
  307. lang: meta.opts.site?.language || "en-US",
  308. background: meta.opts.site?.backgroundColor || "#ffffff",
  309. theme_color: meta.opts.site?.themeColor || "#ffffff",
  310. appleStatusBarStyle: "black-translucent",
  311. display: "standalone",
  312. orientation: "any",
  313. scope: "/",
  314. start_url: "/",
  315. version: "1.0",
  316. logging: false,
  317. pixel_art: false,
  318. loadManifestWithCredentials: false,
  319. manifestMaskable: false,
  320. icons: {
  321. android: true,
  322. appleIcon: true,
  323. appleStartup: true,
  324. favicons: true,
  325. windows: true,
  326. yandex: true,
  327. },
  328. }
  329. try {
  330. const response = await favicons(filePath, configuration)
  331. // Write all generated images to disk
  332. await Promise.all(
  333. response.images.map(async image => {
  334. const outputPath = path.join(fileOutputDir, image.name)
  335. await writeFile(outputPath, image.contents)
  336. }),
  337. )
  338. // Write all generated files (manifests, etc.) to disk
  339. await Promise.all(
  340. response.files.map(async file => {
  341. const outputPath = path.join(fileOutputDir, file.name)
  342. await writeFile(outputPath, file.contents)
  343. }),
  344. )
  345. // Combine HTML meta tags
  346. const htmlMeta = response.html.join("\n ")
  347. return {
  348. detail: {
  349. htmlMeta,
  350. },
  351. paths: [
  352. ...response.images.map(img => path.join(fileOutputDir, img.name)),
  353. ...response.files.map(file => path.join(fileOutputDir, file.name)),
  354. ],
  355. ref: config.name,
  356. }
  357. } catch (error) {
  358. throw new Error(`Failed to generate favicons: ${error.message}`)
  359. }
  360. }
  361. export async function generateTaxonomy({ config }) {
  362. if (!Array.isArray(config.inputs)) {
  363. return {
  364. detail: config.indexOn ? {} : [],
  365. ref: config.name,
  366. }
  367. }
  368. const allValues = config.inputs.reduce((values, curr) => {
  369. const items = curr[config.indexOn] || []
  370. items.forEach(v => values.add(v))
  371. return values
  372. }, new Set())
  373. const orderBy = config.orderBy || "date"
  374. const sortedInputs = config.sortAscending
  375. ? _.sortBy(config.inputs, orderBy)
  376. : _.sortBy(config.inputs, orderBy).reverse()
  377. const taxonomy = config.indexOn
  378. ? [...allValues.values()].reduce((groups, currentGroup) => {
  379. const grouped = {
  380. ...groups,
  381. [currentGroup]: sortedInputs
  382. .filter(item => item[config.indexOn].includes(currentGroup))
  383. .map(item => {
  384. const entry = config.properties
  385. ? config.properties.reduce(
  386. (ent, prop) => ({ ...ent, [prop]: item[prop] }),
  387. {},
  388. )
  389. : item
  390. return entry
  391. }),
  392. }
  393. return grouped
  394. }, {})
  395. : sortedInputs.map(item => {
  396. const entry = config.properties
  397. ? config.properties.reduce(
  398. (ent, prop) => ({ ...ent, [prop]: item[prop] }),
  399. {},
  400. )
  401. : item
  402. return entry
  403. })
  404. return {
  405. detail: taxonomy,
  406. ref: config.name,
  407. }
  408. }