poops 1.2.3 → 1.2.4

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.
@@ -3,7 +3,8 @@ import path from 'node:path'
3
3
  import { Liquid } from 'liquidjs'
4
4
  import { Marked } from 'marked'
5
5
  import { highlightRenderer, highlightCode } from '../highlight.js'
6
- import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities } from '../helpers.js'
6
+ import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities, buildImageTag } 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
 
@@ -75,6 +76,14 @@ export default class LiquidEngine {
75
76
  if (variants.length === 0) return ''
76
77
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
77
78
  })
79
+ engine.registerFilter('exif', (imagePath) => {
80
+ const outputDir = path.join(process.cwd(), markupOut)
81
+ return getImageExif(imagePath, outputDir)
82
+ })
83
+ engine.registerFilter('images', (dirPath) => {
84
+ const outputDir = path.join(process.cwd(), markupOut)
85
+ return listImages(dirPath, outputDir)
86
+ })
78
87
  engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
79
88
  engine.registerFilter('highlight', (code, lang) => {
80
89
  const highlighted = highlightCode(code, lang)
@@ -93,6 +102,10 @@ 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)
@@ -189,32 +202,7 @@ 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
- }
210
-
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(' ')}>`
205
+ return buildImageTag(imagePath, prefix, kwargs, getOutputDir)
218
206
  }
219
207
  })
220
208
  }
@@ -3,7 +3,9 @@ import { globSync } from 'glob'
3
3
  import { Marked } from 'marked'
4
4
  import nunjucks from 'nunjucks'
5
5
  import path from 'node:path'
6
- import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities } from '../helpers.js'
6
+ import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities, buildImageTag } from '../helpers.js'
7
+ import { getImageExif, listImages } from '../image-cache.js'
8
+ import { toPosix } from '../../utils/helpers.js'
7
9
  import { highlightRenderer, highlightCode } from '../highlight.js'
8
10
  import { slugify } from 'book-of-spells'
9
11
  import dayjs from 'dayjs'
@@ -26,7 +28,7 @@ class RelativeLoader extends nunjucks.Loader {
26
28
  if (this.includePaths) {
27
29
  pattern = `{${this.includePaths.join(',')}}/${pattern}`
28
30
  }
29
- fullPath = globSync(path.join(this.templatesDir, pattern))[0]
31
+ fullPath = globSync(toPosix(path.join(this.templatesDir, pattern)))[0]
30
32
  }
31
33
  if (!fs.existsSync(fullPath)) {
32
34
  log({ tag: 'markup', error: true, text: 'Template not found:', link: name })
@@ -108,6 +110,14 @@ export default class NunjucksEngine {
108
110
  if (variants.length === 0) return ''
109
111
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
110
112
  })
113
+ env.addFilter('exif', (imagePath) => {
114
+ const outputDir = path.join(process.cwd(), markupOut)
115
+ return getImageExif(imagePath, outputDir)
116
+ })
117
+ env.addFilter('images', (dirPath) => {
118
+ const outputDir = path.join(process.cwd(), markupOut)
119
+ return listImages(dirPath, outputDir)
120
+ })
111
121
  env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
112
122
  env.addFilter('highlight', (code, lang) => {
113
123
  const highlighted = highlightCode(code, lang)
@@ -126,6 +136,10 @@ export default class NunjucksEngine {
126
136
  this.env.addGlobal(key, value)
127
137
  }
128
138
 
139
+ removeGlobal(key) {
140
+ delete this.env.globals[key]
141
+ }
142
+
129
143
  render(templateName, context) {
130
144
  return new Promise((resolve, reject) => {
131
145
  this.env.getTemplate(templateName).render(context, (error, result) => {
@@ -208,34 +222,7 @@ export class ImageExtension {
208
222
 
209
223
  run(context, imagePath, kwargs) {
210
224
  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(' ')}>`)
225
+ return new nunjucks.runtime.SafeString(buildImageTag(imagePath, prefix, kwargs, this.getOutputDir))
239
226
  }
240
227
  }
241
228
 
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import yaml from 'yaml'
4
+ import { toPosix } from '../utils/helpers.js'
5
+ import { getImageEntry } from './image-cache.js'
4
6
 
5
7
  const frontMatterCache = new Map()
6
8
 
@@ -107,7 +109,90 @@ export function groupby(arr, key, datePart) {
107
109
 
108
110
  const FORMAT_PRIORITY = ['avif', 'webp']
109
111
 
112
+ function escapeRegExp(str) {
113
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
114
+ }
115
+
116
+ // jpeg sources compile to .jpg variants — same format for grouping purposes
117
+ function normalizeFormat(fmt) {
118
+ return fmt === 'jpeg' ? 'jpg' : fmt
119
+ }
120
+
121
+ // Pick best format for srcset (highest priority format that has variants)
122
+ // and the middle-sized original-format variant as the src fallback.
123
+ function pickSrcsetAndSrc(variants, originalExt) {
124
+ originalExt = normalizeFormat(originalExt)
125
+ variants.sort((a, b) => a.width - b.width)
126
+
127
+ const availableFormats = new Set(variants.map(v => normalizeFormat(v.format)))
128
+ let srcsetFormat = null
129
+ for (const fmt of FORMAT_PRIORITY) {
130
+ if (availableFormats.has(fmt)) {
131
+ srcsetFormat = fmt
132
+ break
133
+ }
134
+ }
135
+ if (!srcsetFormat && availableFormats.has(originalExt)) {
136
+ srcsetFormat = originalExt
137
+ }
138
+ if (!srcsetFormat && availableFormats.size > 0) {
139
+ srcsetFormat = [...availableFormats][0]
140
+ }
141
+
142
+ const srcsetVariants = srcsetFormat ? variants.filter(v => normalizeFormat(v.format) === srcsetFormat) : []
143
+
144
+ const originalVariants = variants.filter(v => normalizeFormat(v.format) === originalExt)
145
+ let srcVariant = null
146
+ if (originalVariants.length > 0) {
147
+ srcVariant = originalVariants[Math.floor((originalVariants.length - 1) / 2)]
148
+ } else if (srcsetVariants.length > 0) {
149
+ srcVariant = srcsetVariants[Math.floor((srcsetVariants.length - 1) / 2)]
150
+ }
151
+
152
+ return { srcVariant, srcsetVariants }
153
+ }
154
+
155
+ // Variant discovery from the poops-images compile cache: exact output paths
156
+ // and dimensions, no directory scan. Only `{name}-{width}w.{ext}` outputs are
157
+ // srcset material — named sizes (`-thumb-200w`) and preprocessed outputs
158
+ // (`-blurred-...`) are crops or effects with their own aspect ratios.
159
+ function discoverImageVariantsFromCache(imagePath, outputDir) {
160
+ const found = getImageEntry(imagePath, outputDir)
161
+ if (!found) return null
162
+
163
+ const { entry, prefixDir } = found
164
+ const parsed = path.parse(imagePath)
165
+ const originalExt = parsed.ext.replace('.', '')
166
+ const variantPattern = new RegExp(`^${escapeRegExp(parsed.name)}-(\\d+)w\\.([a-z0-9]+)$`)
167
+ const basePattern = new RegExp(`^${escapeRegExp(parsed.name)}\\.([a-z0-9]+)$`)
168
+
169
+ const variants = []
170
+ let base = null
171
+ for (const out of entry.outputs || []) {
172
+ const file = path.posix.basename(toPosix(out.path))
173
+ const sitePath = prefixDir ? toPosix(path.join(prefixDir, out.path)) : toPosix(out.path)
174
+ let match = file.match(variantPattern)
175
+ if (match) {
176
+ variants.push({ path: sitePath, width: parseInt(match[1], 10), height: out.height, format: match[2] })
177
+ continue
178
+ }
179
+ match = file.match(basePattern)
180
+ if (match && !base) {
181
+ base = { path: sitePath, width: out.width, height: out.height }
182
+ }
183
+ }
184
+
185
+ const { srcVariant, srcsetVariants } = pickSrcsetAndSrc(variants, originalExt)
186
+ // Base output fixes the src extension when the source was converted (heic → jpg)
187
+ const src = srcVariant || base || { path: imagePath }
188
+
189
+ return { src: src.path, variants: srcsetVariants, width: src.width, height: src.height }
190
+ }
191
+
110
192
  export function discoverImageVariants(imagePath, outputDir) {
193
+ const fromCache = discoverImageVariantsFromCache(imagePath, outputDir)
194
+ if (fromCache) return fromCache
195
+
111
196
  const parsed = path.parse(imagePath)
112
197
  const dir = path.join(outputDir, parsed.dir)
113
198
  const baseName = parsed.name
@@ -128,44 +213,14 @@ export function discoverImageVariants(imagePath, outputDir) {
128
213
  const [, name, widthStr, format] = match
129
214
  if (name !== baseName) continue
130
215
  variants.push({
131
- path: path.join(parsed.dir, file),
216
+ path: toPosix(path.join(parsed.dir, file)),
132
217
  width: parseInt(widthStr, 10),
133
218
  format
134
219
  })
135
220
  }
136
221
 
137
- variants.sort((a, b) => a.width - b.width)
138
-
139
- // Pick best format for srcset: highest priority format that has variants
140
- const availableFormats = new Set(variants.map(v => v.format))
141
- let srcsetFormat = null
142
- for (const fmt of FORMAT_PRIORITY) {
143
- if (availableFormats.has(fmt)) {
144
- srcsetFormat = fmt
145
- break
146
- }
147
- }
148
- if (!srcsetFormat && availableFormats.has(originalExt)) {
149
- srcsetFormat = originalExt
150
- }
151
- if (!srcsetFormat && availableFormats.size > 0) {
152
- srcsetFormat = [...availableFormats][0]
153
- }
154
-
155
- const srcsetVariants = srcsetFormat ? variants.filter(v => v.format === srcsetFormat) : []
156
-
157
- // Pick middle-sized variant in original format for src fallback
158
- const originalVariants = variants.filter(v => v.format === originalExt)
159
- let src = imagePath
160
- if (originalVariants.length > 0) {
161
- const mid = Math.floor((originalVariants.length - 1) / 2)
162
- src = originalVariants[mid].path
163
- } else if (srcsetVariants.length > 0) {
164
- const mid = Math.floor((srcsetVariants.length - 1) / 2)
165
- src = srcsetVariants[mid].path
166
- }
167
-
168
- return { src, variants: srcsetVariants }
222
+ const { srcVariant, srcsetVariants } = pickSrcsetAndSrc(variants, originalExt)
223
+ return { src: srcVariant ? srcVariant.path : imagePath, variants: srcsetVariants }
169
224
  }
170
225
 
171
226
  export function replaceOutExtensions(outputPath) {
@@ -200,8 +255,9 @@ export function getRelativePathPrefix(outputDir, fromDir, baseURL) {
200
255
  return baseURL.endsWith('/') ? baseURL : baseURL + '/'
201
256
  }
202
257
 
203
- let relativeDir = path.relative(process.cwd(), outputDir)
204
- const fromRelativeDir = fromDir ? path.relative(process.cwd(), fromDir) : ''
258
+ // getUpDirPrefix splits on `/`, so normalize away native separators
259
+ let relativeDir = toPosix(path.relative(process.cwd(), outputDir))
260
+ const fromRelativeDir = fromDir ? toPosix(path.relative(process.cwd(), fromDir)) : ''
205
261
 
206
262
  if (fromRelativeDir && relativeDir.startsWith(fromRelativeDir)) {
207
263
  relativeDir = relativeDir.replace(fromRelativeDir, '')
@@ -212,10 +268,50 @@ export function getRelativePathPrefix(outputDir, fromDir, baseURL) {
212
268
 
213
269
  export function getPageUrl(outputPath) {
214
270
  outputPath = replaceOutExtensions(outputPath)
215
- return /index\.[a-z]+$/.test(path.basename(outputPath)) ? path.relative(process.cwd(), path.dirname(outputPath)) : path.relative(process.cwd(), outputPath)
271
+ return toPosix(/index\.[a-z]+$/.test(path.basename(outputPath)) ? path.relative(process.cwd(), path.dirname(outputPath)) : path.relative(process.cwd(), outputPath))
216
272
  }
217
273
 
218
274
  export function getPageUrlRelativeToOutput(outputPath, outputDir) {
219
275
  const pageUrl = getPageUrl(outputPath)
220
- return path.relative(outputDir, pageUrl)
276
+ return toPosix(path.relative(outputDir, pageUrl))
277
+ }
278
+
279
+ export function escapeAttr(value) {
280
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
281
+ }
282
+
283
+ // Shared by the nunjucks and liquid `image` tags — attribute values may come
284
+ // from front-matter/user data, so they are escaped here, once for both engines.
285
+ export function buildImageTag(imagePath, prefix, kwargs, getOutputDir) {
286
+ const alt = (kwargs && kwargs.alt) || ''
287
+ const loading = (kwargs && kwargs.loading) || 'lazy'
288
+ const isSvg = imagePath.endsWith('.svg')
289
+ const attrs = [`alt="${escapeAttr(alt)}"`]
290
+
291
+ if (isSvg) {
292
+ attrs.unshift(`src="${escapeAttr(prefix + imagePath)}"`)
293
+ } else {
294
+ const { src, variants, width, height } = discoverImageVariants(imagePath, getOutputDir())
295
+ const sizes = (kwargs && kwargs.sizes) || '100vw'
296
+ attrs.unshift(`src="${escapeAttr(prefix + src)}"`)
297
+ if (width && height && !(kwargs && (kwargs.width || kwargs.height))) {
298
+ attrs.push(`width="${width}"`, `height="${height}"`)
299
+ }
300
+ if (variants.length > 0) {
301
+ const srcsetVal = variants.map(v => `${prefix}${v.path} ${v.width}w`).join(', ')
302
+ attrs.push(`srcset="${escapeAttr(srcsetVal)}"`)
303
+ attrs.push(`sizes="${escapeAttr(sizes)}"`)
304
+ }
305
+ }
306
+
307
+ attrs.push(`loading="${escapeAttr(loading)}"`)
308
+ if (kwargs) {
309
+ const skip = new Set(['alt', 'sizes', 'loading'])
310
+ for (const [key, val] of Object.entries(kwargs)) {
311
+ // `__keywords` is nunjucks' kwargs marker, never a real attribute
312
+ if (key.startsWith('__') || skip.has(key)) continue
313
+ attrs.push(`${key}="${escapeAttr(val)}"`)
314
+ }
315
+ }
316
+ return `<img ${attrs.join(' ')}>`
221
317
  }
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { toPosix } from '../utils/helpers.js'
4
+
5
+ // Compile cache written by poops-images (https://github.com/stamat/poops-images)
6
+ // next to the images it generates. Holds exact output paths, dimensions and EXIF.
7
+ const CACHE_FILENAME = '.poops-images-cache.json'
8
+
9
+ const cacheFileCache = new Map()
10
+
11
+ function readCacheFile(cachePath) {
12
+ let stat
13
+ try {
14
+ stat = fs.statSync(cachePath)
15
+ } catch {
16
+ return null
17
+ }
18
+
19
+ const cached = cacheFileCache.get(cachePath)
20
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
21
+ return cached.data
22
+ }
23
+
24
+ let data = null
25
+ try {
26
+ data = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
27
+ } catch {
28
+ return null
29
+ }
30
+
31
+ cacheFileCache.set(cachePath, { mtimeMs: stat.mtimeMs, size: stat.size, data })
32
+ return data
33
+ }
34
+
35
+ export function clearImageCache() {
36
+ cacheFileCache.clear()
37
+ }
38
+
39
+ // Finds the poops-images cache entry for a site-relative image path.
40
+ // Walks from the image's directory up to outputDir, since the cache file sits
41
+ // at the root of the images output dir, which may be a subdirectory of the
42
+ // site output (e.g. dist/images/.poops-images-cache.json).
43
+ // Returns { entry, prefixDir } where prefixDir is the cache dir relative to
44
+ // outputDir (output paths in the cache are relative to the cache dir), or null.
45
+ export function getImageEntry(imagePath, outputDir) {
46
+ const root = path.resolve(outputDir)
47
+ let dir = path.resolve(root, path.dirname(imagePath))
48
+ if (!dir.startsWith(root)) return null
49
+
50
+ const target = toPosix(path.relative(root, path.resolve(root, imagePath)))
51
+ const targetNoExt = target.replace(/\.[^./]+$/, '')
52
+
53
+ while (true) {
54
+ const data = readCacheFile(path.join(dir, CACHE_FILENAME))
55
+ if (data && data.entries) {
56
+ const prefixDir = toPosix(path.relative(root, dir))
57
+ const rel = prefixDir ? target.slice(prefixDir.length + 1) : target
58
+ let entry = data.entries[rel]
59
+ if (!entry) {
60
+ // Output extension may differ from the source key (heic → jpg, jpeg → jpg)
61
+ const relNoExt = prefixDir ? targetNoExt.slice(prefixDir.length + 1) : targetNoExt
62
+ for (const [key, value] of Object.entries(data.entries)) {
63
+ if (key.replace(/\.[^./]+$/, '') === relNoExt) {
64
+ entry = value
65
+ break
66
+ }
67
+ }
68
+ }
69
+ if (entry) return { entry, prefixDir }
70
+ }
71
+ if (dir === root) return null
72
+ dir = path.dirname(dir)
73
+ }
74
+ }
75
+
76
+ export function getImageExif(imagePath, outputDir) {
77
+ const found = getImageEntry(imagePath, outputDir)
78
+ return (found && found.entry.exif) || null
79
+ }
80
+
81
+ // Lists all cache entries under a site-relative directory, gallery-ready:
82
+ // site-relative paths, `date` flattened from EXIF (file mtime fallback) so
83
+ // engine-native sort and the groupby filter work without touching nested exif.
84
+ export function listImages(dirPath, outputDir) {
85
+ const root = path.resolve(outputDir)
86
+ const clean = toPosix(dirPath || '').replace(/^\/+|\/+$/g, '')
87
+ let dir = path.resolve(root, clean)
88
+ if (!dir.startsWith(root)) return []
89
+
90
+ while (true) {
91
+ const data = readCacheFile(path.join(dir, CACHE_FILENAME))
92
+ if (data && data.entries) {
93
+ const prefixDir = toPosix(path.relative(root, dir))
94
+ const scope = toPosix(path.relative(dir, path.resolve(root, clean)))
95
+ const sitePath = (p) => prefixDir ? toPosix(path.join(prefixDir, p)) : toPosix(p)
96
+
97
+ const images = []
98
+ for (const [key, entry] of Object.entries(data.entries)) {
99
+ if (scope && !key.startsWith(scope + '/')) continue
100
+ images.push({
101
+ path: sitePath(key),
102
+ width: entry.width,
103
+ height: entry.height,
104
+ date: (entry.exif && entry.exif.dateTime) || (entry.mtime ? new Date(entry.mtime).toISOString() : null),
105
+ exif: entry.exif || null,
106
+ outputs: (entry.outputs || []).map(o => ({ ...o, path: sitePath(o.path) }))
107
+ })
108
+ }
109
+ return images
110
+ }
111
+ if (dir === root) return []
112
+ dir = path.dirname(dir)
113
+ }
114
+ }
@@ -116,7 +116,9 @@ export function generateSearchIndex(pageEntries, outputDir, config) {
116
116
 
117
117
  entries = applyGlobalFrequencyCeiling(entries, config.globalFrequencyCeiling)
118
118
 
119
- const outputPath = path.join(process.cwd(), outputDir, config.output)
119
+ // resolve, not join: outputDir may be absolute (join would mangle it,
120
+ // e.g. cross-drive temp dirs on Windows)
121
+ const outputPath = path.resolve(process.cwd(), outputDir, config.output)
120
122
  fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
121
123
  log({ tag: 'indexer', text: 'Generated search index:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
122
124
  }
@@ -143,7 +145,9 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
143
145
 
144
146
  xml += '</urlset>\n'
145
147
 
146
- const outputPath = path.join(process.cwd(), outputDir, config.output)
148
+ // resolve, not join: outputDir may be absolute (join would mangle it,
149
+ // e.g. cross-drive temp dirs on Windows)
150
+ const outputPath = path.resolve(process.cwd(), outputDir, config.output)
147
151
  fs.writeFileSync(outputPath, xml)
148
152
  log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
149
153
  }
package/lib/markups.js CHANGED
@@ -1,4 +1,4 @@
1
- import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime } from './utils/helpers.js'
1
+ import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
2
2
  import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
3
3
  import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
4
4
  import { generateIndexFiles } from './markup/indexer.js'
@@ -36,7 +36,6 @@ export default class Markups {
36
36
  this.searchIndexConfig = moduleConfig.options.searchIndex || moduleConfig.searchIndex
37
37
  this.sitemapConfig = moduleConfig.options.sitemap || moduleConfig.sitemap
38
38
  this.baseURL = moduleConfig.baseURL || moduleConfig.options.baseURL || null
39
- this.dataFiles = []
40
39
 
41
40
  // Instantiate engine
42
41
  const EngineClass = ENGINES[engineName]
@@ -71,8 +70,8 @@ export default class Markups {
71
70
  }
72
71
  }
73
72
 
74
- const data = moduleConfig.data || moduleConfig.options.data
75
- this.loadDataFiles(data)
73
+ this.dataConfig = moduleConfig.data || moduleConfig.options.data
74
+ this.loadDataFiles(this.dataConfig)
76
75
 
77
76
  if (!moduleConfig.out) {
78
77
  moduleConfig.out = this.markupOut
@@ -92,7 +91,7 @@ export default class Markups {
92
91
  for (const file of files) {
93
92
  const fullPath = path.join(process.cwd(), dataDir, file)
94
93
  if (pathIsDirectory(fullPath)) {
95
- const dirFiles = globSync(path.join(fullPath, '**/*.+(json|yml|yaml)'))
94
+ const dirFiles = globSync(toPosix(path.join(fullPath, '**/*.+(json|yml|yaml)')))
96
95
  for (const f of dirFiles) {
97
96
  resolved.push(path.relative(path.join(process.cwd(), dataDir), f))
98
97
  }
@@ -101,25 +100,59 @@ export default class Markups {
101
100
  }
102
101
  }
103
102
 
104
- if (!this.dataFiles.length) this.dataFiles = resolved
105
-
103
+ const loadedKeys = new Set()
106
104
  for (const dataFile of resolved) {
107
105
  try {
108
106
  const data = readDataFile(path.join(process.cwd(), dataDir, dataFile))
109
107
  const globalKeyName = path.basename(dataFile, path.extname(dataFile)).replace(/[.\-\s]/g, '_')
110
108
  this.engine.setGlobal(globalKeyName, data)
109
+ loadedKeys.add(globalKeyName)
111
110
  } catch (err) {
112
111
  log({ tag: 'error', text: 'Data file not found:', link: dataFile })
113
112
  continue
114
113
  }
115
114
  }
115
+
116
+ // A deleted data file must also drop its global, or stale data renders forever
117
+ if (this.dataGlobalKeys) {
118
+ for (const key of this.dataGlobalKeys) {
119
+ if (!loadedKeys.has(key)) this.engine.removeGlobal(key)
120
+ }
121
+ }
122
+ this.dataGlobalKeys = loadedKeys
116
123
  }
117
124
 
118
125
  reloadDataFiles() {
119
- this.loadDataFiles(this.dataFiles)
126
+ this.loadDataFiles(this.dataConfig)
120
127
  return Promise.resolve()
121
128
  }
122
129
 
130
+ // Maps a deleted source path to its build output and removes it.
131
+ // compile() only globs existing sources, so it can never clean up
132
+ // after a deletion — this is the only place stale output gets removed.
133
+ removeOutput(sourcePath) {
134
+ if (!this.engine) return
135
+ const markupIn = path.join(process.cwd(), this.markupIn)
136
+ const rel = path.relative(markupIn, path.join(process.cwd(), sourcePath))
137
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return
138
+
139
+ const mapped = path.join(process.cwd(), this.markupOut, rel)
140
+
141
+ // rel === '' with a directory output would be the whole out dir —
142
+ // never remove that, it also holds css/js from other modules
143
+ if (rel !== '' && fs.existsSync(mapped) && fs.statSync(mapped).isDirectory()) {
144
+ fs.rmSync(mapped, { recursive: true })
145
+ log({ tag: this.logTag, text: 'Removed:', link: path.relative(process.cwd(), mapped) })
146
+ return
147
+ }
148
+
149
+ const outFile = replaceOutExtensions(mapped)
150
+ if (fs.existsSync(outFile) && !fs.statSync(outFile).isDirectory()) {
151
+ fs.unlinkSync(outFile)
152
+ log({ tag: this.logTag, text: 'Removed:', link: path.relative(process.cwd(), outFile) })
153
+ }
154
+ }
155
+
123
156
  generateMarkupGlobPattern(excludes) {
124
157
  let markupDefaultExcludes = ['node_modules', '.git', '.svn', '.hg']
125
158
 
@@ -172,9 +205,10 @@ export default class Markups {
172
205
 
173
206
  async compileDirectory(markupIn, collectionData, pageEntries) {
174
207
  const markupStart = performance.now()
208
+ // glob patterns must use `/` — on Windows `\` is an escape character
175
209
  const markupFiles = [
176
- ...globSync(path.join(markupIn, this.generateMarkupGlobPattern(this.includePaths))),
177
- ...globSync(path.join(markupIn, `*.+(${this.engine.markupExtensions})`))
210
+ ...globSync(toPosix(path.join(markupIn, this.generateMarkupGlobPattern(this.includePaths)))),
211
+ ...globSync(toPosix(path.join(markupIn, `*.+(${this.engine.markupExtensions})`)))
178
212
  ]
179
213
  const compilePromises = []
180
214
  const indexableExtensions = this.engine.indexableExtensions
@@ -302,6 +336,16 @@ export default class Markups {
302
336
  if (!moduleConfig || !moduleConfig.in) return
303
337
 
304
338
  if (this.config.reactorData) {
339
+ // A removed reactor component must also drop its injected global,
340
+ // same staleness rule as data files in loadDataFiles()
341
+ const reactorKeys = new Set(Object.keys(this.config.reactorData))
342
+ if (this.reactorGlobalKeys) {
343
+ for (const key of this.reactorGlobalKeys) {
344
+ if (!reactorKeys.has(key)) this.engine.removeGlobal(key)
345
+ }
346
+ }
347
+ this.reactorGlobalKeys = reactorKeys
348
+
305
349
  for (const [name, html] of Object.entries(this.config.reactorData)) {
306
350
  this.engine.setGlobal(name, html)
307
351
  }