processors.js 10 KB

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