processors.js 11 KB

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