processors.js 9.6 KB

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