poops 1.2.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/images.js ADDED
@@ -0,0 +1,57 @@
1
+ import log from './utils/log.js'
2
+
3
+ // Runs poops-images (https://github.com/stamat/poops-images) as a regular
4
+ // runner when a `config.images` block is present AND the package is installed.
5
+ // poops-images stays an optional peer dependency — sharp never becomes a hard
6
+ // dep of poops. The markup engines read the cache poops-images writes, so this
7
+ // runner must execute before markups.compile().
8
+ export default class Images {
9
+ constructor(config) {
10
+ this.config = config
11
+ this.processor = null // lazily created on first compile()
12
+ this.disabled = false // no images config, or poops-images not installed
13
+ }
14
+
15
+ async init() {
16
+ if (this.processor || this.disabled) return
17
+ if (!this.config.images) { this.disabled = true; return }
18
+
19
+ let ImageProcessor
20
+ try {
21
+ ImageProcessor = (await import('poops-images')).default
22
+ } catch (err) {
23
+ // Native ESM throws ERR_MODULE_NOT_FOUND; CJS-style/jest resolvers throw
24
+ // MODULE_NOT_FOUND. Either means the optional dep simply isn't installed.
25
+ if (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND') {
26
+ log({ tag: 'image', warn: true, text: 'images config found but poops-images is not installed — run: npm i poops-images' })
27
+ this.disabled = true
28
+ return
29
+ }
30
+ throw err
31
+ }
32
+
33
+ // Quiet by default inside poops — one summary line instead of per-image
34
+ // logs drowning the other runners. Opt back in with "verbose": true.
35
+ // A bad images config throws here, propagating to the build's step() so
36
+ // the build is marked failed rather than silently skipping images.
37
+ this.processor = new ImageProcessor({ verbose: false, ...this.config.images })
38
+ }
39
+
40
+ async compile() {
41
+ await this.init()
42
+ if (!this.processor) return
43
+ const stats = await this.processor.processAll()
44
+ // Route through poops' log so hasLoggedErrors() flips the build exit code.
45
+ // Older poops-images without the errors field: undefined is falsy, no-op.
46
+ if (stats.errors > 0) {
47
+ log({ tag: 'image', error: true, text: `${stats.errors} image(s) failed to process` })
48
+ }
49
+ }
50
+
51
+ // Watch mode: a deleted source image removes its generated variants + cache entry.
52
+ async remove(file) {
53
+ await this.init()
54
+ if (!this.processor) return
55
+ this.processor.removeSource(file)
56
+ }
57
+ }
@@ -2,12 +2,13 @@ import fs from 'node:fs'
2
2
  import { globSync } from 'glob'
3
3
  import path from 'node:path'
4
4
  import log from '../utils/log.js'
5
- import { mkDir } from '../utils/helpers.js'
6
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, parseFrontMatter, wordcount } from './helpers.js'
5
+ import { mkDir, toPosix } from '../utils/helpers.js'
6
+ import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount } from './helpers.js'
7
7
 
8
8
  export function getSingleCollectionData(markupInDir, collectionName) {
9
9
  const collectionData = []
10
- globSync(path.join(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)'), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
10
+ // glob patterns must use `/` on Windows `\` is an escape character
11
+ globSync(toPosix(path.resolve(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)')), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
11
12
  let frontMatter = {}
12
13
 
13
14
  let content = ''
@@ -23,13 +24,16 @@ export function getSingleCollectionData(markupInDir, collectionName) {
23
24
  if (frontMatter.published === false) return
24
25
 
25
26
  if (!frontMatter.date) {
26
- frontMatter.date = fs.statSync(file).ctime.toISOString().slice(0, 16)
27
+ // mtime is only a local-dev approximation — git clone resets it, so CI
28
+ // builds date undated posts "now". The warning is the real fix.
29
+ frontMatter.date = fs.statSync(file).mtime.toISOString().slice(0, 16)
30
+ log({ tag: 'markup', warn: true, text: 'No date in front matter, falling back to file mtime:', link: file })
27
31
  }
28
32
  frontMatter.wordcount = wordcount(content)
29
33
  frontMatter.fileName = path.basename(file)
30
34
  frontMatter.filePath = path.relative(process.cwd(), file)
31
35
  frontMatter.collection = collectionName
32
- frontMatter.url = path.join(collectionName, path.basename(frontMatter.filePath))
36
+ frontMatter.url = toPosix(path.join(collectionName, path.basename(frontMatter.filePath)))
33
37
 
34
38
  frontMatter.url = replaceOutExtensions(frontMatter.url)
35
39
 
@@ -43,7 +47,7 @@ export function getSingleCollectionData(markupInDir, collectionName) {
43
47
  }
44
48
 
45
49
  export function collectionAutoDiscovery(markupInDir) {
46
- const indexFiles = globSync(path.join(process.cwd(), markupInDir, '/**/index.+(html|njk|liquid|md)'))
50
+ const indexFiles = globSync(toPosix(path.resolve(process.cwd(), markupInDir, '**/index.+(html|njk|liquid|md)')))
47
51
 
48
52
  const collectionData = {}
49
53
 
@@ -180,11 +184,25 @@ export function buildCollectionPaginationData(collectionData) {
180
184
  }
181
185
 
182
186
  export function getCollectionIndexFile(markupInDir, collectionName) {
183
- const indexFiles = globSync(path.join(process.cwd(), markupInDir, collectionName, 'index.+(html|njk|liquid|md)'))
187
+ const indexFiles = globSync(toPosix(path.resolve(process.cwd(), markupInDir, collectionName, 'index.+(html|njk|liquid|md)')))
184
188
  if (indexFiles.length === 0) return null
185
189
  return indexFiles[0]
186
190
  }
187
191
 
192
+ export function pruneStalePaginationDirs(collectionName, markupInDir, markupOutDir, keepPages) {
193
+ const outDir = path.resolve(process.cwd(), markupOutDir, collectionName)
194
+ if (!fs.existsSync(outDir)) return
195
+
196
+ for (const entry of fs.readdirSync(outDir)) {
197
+ const pageNumber = parseInt(entry, 10)
198
+ // pagination only ever writes out/<name>/2..totalPages/
199
+ if (String(pageNumber) !== entry || pageNumber < 2 || pageNumber <= keepPages) continue
200
+ // numeric dir mirrored from a real source dir — not pagination output
201
+ if (fs.existsSync(path.resolve(process.cwd(), markupInDir, collectionName, entry))) continue
202
+ fs.rmSync(path.join(outDir, entry), { recursive: true, force: true })
203
+ }
204
+ }
205
+
188
206
  export function generateCollectionPaginationPages(collectionData, markupInDir, markupOutDir, compileEntryFn, baseURL) {
189
207
  if (!collectionData) return []
190
208
 
@@ -199,6 +217,11 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
199
217
  collection.pages = [collection.items]
200
218
  }
201
219
 
220
+ // a shrunk page count (or removed index) leaves stale out/<name>/N/ dirs
221
+ pruneStalePaginationDirs(collection.name, markupInDir, markupOutDir, file ? collection.totalPages : 1)
222
+
223
+ if (!file) continue
224
+
202
225
  for (let i = 0; i < collection.totalPages; i++) {
203
226
  const pageNumber = i + 1
204
227
  const pageUrl = pageNumber === 1 ? collection.name : `${collection.name}/${pageNumber}`
@@ -222,24 +245,23 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
222
245
  prevPageUrl
223
246
  }
224
247
 
225
- const markupOut = path.join(process.cwd(), markupOutDir, pageUrl, 'index.html')
226
- const fromPath = path.join(process.cwd(), markupOutDir)
248
+ const markupOut = path.resolve(process.cwd(), markupOutDir, pageUrl, 'index.html')
249
+ const fromPath = path.resolve(process.cwd(), markupOutDir)
227
250
  const markupOutDirFull = path.dirname(markupOut)
228
251
 
229
- mkDir(markupOutDirFull)
230
-
231
252
  const context = {
232
253
  ...collectionData,
233
254
  [collectionName]: pageSnapshot,
234
255
  relativePathPrefix: getRelativePathPrefix(markupOutDirFull, fromPath, baseURL),
235
- _url: getPageUrl(markupOut)
236
- }
237
-
238
- if (!file) {
239
- continue
256
+ // output-relative so page.url matches nav.json/index urls, same as compileDirectory
257
+ _url: getPageUrlRelativeToOutput(markupOut, markupOutDir)
240
258
  }
241
259
 
242
- const compilePromise = compileEntryFn(file, context).then(({ result }) => {
260
+ // mkDir only when a page is actually written: no empty pagination dirs
261
+ // for index-less or unpublished (skipped) collection indexes
262
+ const compilePromise = compileEntryFn(file, context).then(({ result, skipped }) => {
263
+ if (skipped) return
264
+ mkDir(markupOutDirFull)
243
265
  fs.writeFileSync(markupOut, result)
244
266
  })
245
267
  compilePromises.push(compilePromise)
@@ -1,19 +1,18 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { Liquid } from 'liquidjs'
4
- import { Marked } from 'marked'
5
- import { highlightRenderer, highlightCode } from '../highlight.js'
6
- import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities } from '../helpers.js'
4
+ import { highlightCode } from '../highlight.js'
5
+ import { marked, renderMarkdown } from '../renderer.js'
6
+ import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
7
+ import { getImageExif, listImages } from '../image-cache.js'
7
8
  import { slugify } from 'book-of-spells'
8
9
  import dayjs from 'dayjs'
9
10
 
10
- const marked = new Marked({ renderer: highlightRenderer })
11
-
12
11
  export default class LiquidEngine {
13
12
  constructor(templatesDir, includePaths) {
14
13
  const roots = [templatesDir]
15
14
  for (const inc of includePaths || []) {
16
- roots.push(path.join(templatesDir, inc))
15
+ roots.push(path.resolve(templatesDir, inc))
17
16
  }
18
17
  // Also add any _* directories as include roots
19
18
  try {
@@ -47,6 +46,7 @@ export default class LiquidEngine {
47
46
  engine.registerFilter('slugify', (str) => slugify(str))
48
47
  engine.registerFilter('jsonify', (obj) => JSON.stringify(obj))
49
48
  engine.registerFilter('markdown', (str) => marked.parse(str))
49
+ engine.registerFilter('toc', (html) => renderToc(String(html || '')))
50
50
  engine.registerFilter('date', (str, template) => {
51
51
  const fmt = template || timeDateFormat
52
52
  if (!fmt) return str
@@ -70,11 +70,19 @@ export default class LiquidEngine {
70
70
  return content
71
71
  })
72
72
  engine.registerFilter('srcset', (imagePath) => {
73
- const outputDir = path.join(process.cwd(), markupOut)
73
+ const outputDir = path.resolve(process.cwd(), markupOut)
74
74
  const { variants } = discoverImageVariants(imagePath, outputDir)
75
75
  if (variants.length === 0) return ''
76
76
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
77
77
  })
78
+ engine.registerFilter('exif', (imagePath) => {
79
+ const outputDir = path.resolve(process.cwd(), markupOut)
80
+ return getImageExif(imagePath, outputDir)
81
+ })
82
+ engine.registerFilter('images', (dirPath) => {
83
+ const outputDir = path.resolve(process.cwd(), markupOut)
84
+ return listImages(dirPath, outputDir)
85
+ })
78
86
  engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
79
87
  engine.registerFilter('highlight', (code, lang) => {
80
88
  const highlighted = highlightCode(code, lang)
@@ -86,6 +94,7 @@ export default class LiquidEngine {
86
94
  registerTags(getOutputDir) {
87
95
  registerGoogleFontsTag(this.engine)
88
96
  registerImageTag(this.engine, getOutputDir)
97
+ registerPaginationTag(this.engine)
89
98
  registerHighlightTag(this.engine)
90
99
  }
91
100
 
@@ -93,13 +102,17 @@ export default class LiquidEngine {
93
102
  this.globals[key] = value
94
103
  }
95
104
 
105
+ removeGlobal(key) {
106
+ delete this.globals[key]
107
+ }
108
+
96
109
  async render(templateName, context) {
97
110
  let source
98
111
  const frontMatterResult = parseFrontMatter(templateName)
99
112
  source = frontMatterResult.content
100
113
 
101
114
  if (path.extname(templateName) === '.md') {
102
- source = decodeTemplateEntities(marked.parse(source))
115
+ source = renderMarkdown(source)
103
116
  }
104
117
 
105
118
  const frontMatter = context.page || {}
@@ -189,32 +202,20 @@ function registerImageTag(engine, getOutputDir) {
189
202
  }
190
203
 
191
204
  const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
192
- const alt = kwargs.alt || ''
193
- const loading = kwargs.loading || 'lazy'
194
- const isSvg = imagePath.endsWith('.svg')
195
- const attrs = [`alt="${alt}"`]
196
-
197
- if (isSvg) {
198
- attrs.unshift(`src="${prefix}${imagePath}"`)
199
- } else {
200
- const outputDir = getOutputDir()
201
- const { src, variants } = discoverImageVariants(imagePath, outputDir)
202
- const sizes = kwargs.sizes || '100vw'
203
- attrs.unshift(`src="${prefix}${src}"`)
204
- if (variants.length > 0) {
205
- const srcsetVal = variants.map(v => `${prefix}${v.path} ${v.width}w`).join(', ')
206
- attrs.push(`srcset="${srcsetVal}"`)
207
- attrs.push(`sizes="${sizes}"`)
208
- }
209
- }
205
+ return buildImageTag(imagePath, prefix, kwargs, getOutputDir)
206
+ }
207
+ })
208
+ }
210
209
 
211
- attrs.push(`loading="${loading}"`)
212
- const skip = new Set(['alt', 'sizes', 'loading'])
213
- for (const [key, val] of Object.entries(kwargs)) {
214
- if (skip.has(key)) continue
215
- attrs.push(`${key}="${val}"`)
216
- }
217
- return `<img ${attrs.join(' ')}>`
210
+ function registerPaginationTag(engine) {
211
+ engine.registerTag('pagination', {
212
+ parse(tagToken) {
213
+ this.value = tagToken.args.trim()
214
+ },
215
+ * render(ctx) {
216
+ const pagination = yield this.liquid.evalValue(this.value, ctx)
217
+ const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
218
+ return buildPaginationTag(pagination, prefix)
218
219
  }
219
220
  })
220
221
  }
@@ -1,16 +1,16 @@
1
1
  import fs from 'node:fs'
2
2
  import { globSync } from 'glob'
3
- import { Marked } from 'marked'
4
3
  import nunjucks from 'nunjucks'
5
4
  import path from 'node:path'
6
- import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities } from '../helpers.js'
7
- import { highlightRenderer, highlightCode } from '../highlight.js'
5
+ import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
6
+ import { getImageExif, listImages } from '../image-cache.js'
7
+ import { toPosix } from '../../utils/helpers.js'
8
+ import { highlightCode } from '../highlight.js'
9
+ import { marked, renderMarkdown } from '../renderer.js'
8
10
  import { slugify } from 'book-of-spells'
9
11
  import dayjs from 'dayjs'
10
12
  import log from '../../utils/log.js'
11
13
 
12
- const marked = new Marked({ renderer: highlightRenderer })
13
-
14
14
  class RelativeLoader extends nunjucks.Loader {
15
15
  constructor(templatesDir, includePaths) {
16
16
  super()
@@ -26,7 +26,7 @@ class RelativeLoader extends nunjucks.Loader {
26
26
  if (this.includePaths) {
27
27
  pattern = `{${this.includePaths.join(',')}}/${pattern}`
28
28
  }
29
- fullPath = globSync(path.join(this.templatesDir, pattern))[0]
29
+ fullPath = globSync(toPosix(path.join(this.templatesDir, pattern)))[0]
30
30
  }
31
31
  if (!fs.existsSync(fullPath)) {
32
32
  log({ tag: 'markup', error: true, text: 'Template not found:', link: name })
@@ -46,7 +46,7 @@ class RelativeLoader extends nunjucks.Loader {
46
46
  }
47
47
 
48
48
  if (path.extname(fullPath) === '.md') {
49
- source = decodeTemplateEntities(marked.parse(source))
49
+ source = renderMarkdown(source)
50
50
  }
51
51
 
52
52
  if (frontMatter.layout) {
@@ -80,6 +80,12 @@ export default class NunjucksEngine {
80
80
  env.addFilter('slugify', slugify)
81
81
  env.addFilter('jsonify', (obj) => JSON.stringify(obj))
82
82
  env.addFilter('markdown', (str) => marked.parse(str))
83
+ env.addFilter('toc', (html) => {
84
+ const toc = renderToc(String(html || ''))
85
+ // plain '' (falsy) when there are no headings, so `{% if x | toc %}` can
86
+ // skip an empty TOC column; SafeString otherwise to keep the markup raw
87
+ return toc ? new nunjucks.runtime.SafeString(toc) : ''
88
+ })
83
89
  env.addFilter('concat', (arr, value) => {
84
90
  if (!Array.isArray(arr)) return [value]
85
91
  return arr.concat(value)
@@ -103,11 +109,19 @@ export default class NunjucksEngine {
103
109
  return dayjs(date).format(fmt)
104
110
  })
105
111
  env.addFilter('srcset', (imagePath) => {
106
- const outputDir = path.join(process.cwd(), markupOut)
112
+ const outputDir = path.resolve(process.cwd(), markupOut)
107
113
  const { variants } = discoverImageVariants(imagePath, outputDir)
108
114
  if (variants.length === 0) return ''
109
115
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
110
116
  })
117
+ env.addFilter('exif', (imagePath) => {
118
+ const outputDir = path.resolve(process.cwd(), markupOut)
119
+ return getImageExif(imagePath, outputDir)
120
+ })
121
+ env.addFilter('images', (dirPath) => {
122
+ const outputDir = path.resolve(process.cwd(), markupOut)
123
+ return listImages(dirPath, outputDir)
124
+ })
111
125
  env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
112
126
  env.addFilter('highlight', (code, lang) => {
113
127
  const highlighted = highlightCode(code, lang)
@@ -119,6 +133,7 @@ export default class NunjucksEngine {
119
133
  registerTags(getOutputDir) {
120
134
  this.env.addExtension('GoogleFontsExtension', new GoogleFontsExtension())
121
135
  this.env.addExtension('ImageExtension', new ImageExtension(getOutputDir))
136
+ this.env.addExtension('PaginationExtension', new PaginationExtension())
122
137
  this.env.addExtension('HighlightExtension', new HighlightExtension())
123
138
  }
124
139
 
@@ -126,6 +141,10 @@ export default class NunjucksEngine {
126
141
  this.env.addGlobal(key, value)
127
142
  }
128
143
 
144
+ removeGlobal(key) {
145
+ delete this.env.globals[key]
146
+ }
147
+
129
148
  render(templateName, context) {
130
149
  return new Promise((resolve, reject) => {
131
150
  this.env.getTemplate(templateName).render(context, (error, result) => {
@@ -208,34 +227,7 @@ export class ImageExtension {
208
227
 
209
228
  run(context, imagePath, kwargs) {
210
229
  const prefix = context.lookup('relativePathPrefix') || ''
211
- const alt = (kwargs && kwargs.alt) || ''
212
- const loading = (kwargs && kwargs.loading) || 'lazy'
213
- const isSvg = imagePath.endsWith('.svg')
214
- const attrs = [`alt="${alt}"`]
215
-
216
- if (isSvg) {
217
- attrs.unshift(`src="${prefix}${imagePath}"`)
218
- } else {
219
- const outputDir = this.getOutputDir()
220
- const { src, variants } = discoverImageVariants(imagePath, outputDir)
221
- const sizes = (kwargs && kwargs.sizes) || '100vw'
222
- attrs.unshift(`src="${prefix}${src}"`)
223
- if (variants.length > 0) {
224
- const srcsetVal = variants.map(v => `${prefix}${v.path} ${v.width}w`).join(', ')
225
- attrs.push(`srcset="${srcsetVal}"`)
226
- attrs.push(`sizes="${sizes}"`)
227
- }
228
- }
229
-
230
- attrs.push(`loading="${loading}"`)
231
- if (kwargs) {
232
- const skip = new Set(['alt', 'sizes', 'loading'])
233
- for (const [key, val] of Object.entries(kwargs)) {
234
- if (key.startsWith('__') || skip.has(key)) continue
235
- attrs.push(`${key}="${val}"`)
236
- }
237
- }
238
- return new nunjucks.runtime.SafeString(`<img ${attrs.join(' ')}>`)
230
+ return new nunjucks.runtime.SafeString(buildImageTag(imagePath, prefix, kwargs, this.getOutputDir))
239
231
  }
240
232
  }
241
233
 
@@ -260,3 +252,19 @@ export class HighlightExtension {
260
252
  )
261
253
  }
262
254
  }
255
+
256
+ export class PaginationExtension {
257
+ constructor() { this.tags = ['pagination'] }
258
+
259
+ parse(parser, nodes) {
260
+ const tok = parser.nextToken()
261
+ const args = parser.parseSignature(null, true)
262
+ parser.advanceAfterBlockEnd(tok.value)
263
+ return new nodes.CallExtension(this, 'run', args)
264
+ }
265
+
266
+ run(context, pagination) {
267
+ const prefix = context.lookup('relativePathPrefix') || ''
268
+ return new nunjucks.runtime.SafeString(buildPaginationTag(pagination, prefix))
269
+ }
270
+ }