poops 1.2.2 → 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 } 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,15 @@ 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
+ })
87
+ engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
78
88
  engine.registerFilter('highlight', (code, lang) => {
79
89
  const highlighted = highlightCode(code, lang)
80
90
  const langClass = lang ? ` language-${lang}` : ''
@@ -92,13 +102,17 @@ export default class LiquidEngine {
92
102
  this.globals[key] = value
93
103
  }
94
104
 
105
+ removeGlobal(key) {
106
+ delete this.globals[key]
107
+ }
108
+
95
109
  async render(templateName, context) {
96
110
  let source
97
111
  const frontMatterResult = parseFrontMatter(templateName)
98
112
  source = frontMatterResult.content
99
113
 
100
114
  if (path.extname(templateName) === '.md') {
101
- source = marked.parse(source)
115
+ source = decodeTemplateEntities(marked.parse(source))
102
116
  }
103
117
 
104
118
  const frontMatter = context.page || {}
@@ -188,32 +202,7 @@ function registerImageTag(engine, getOutputDir) {
188
202
  }
189
203
 
190
204
  const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
191
- const alt = kwargs.alt || ''
192
- const loading = kwargs.loading || 'lazy'
193
- const isSvg = imagePath.endsWith('.svg')
194
- const attrs = [`alt="${alt}"`]
195
-
196
- if (isSvg) {
197
- attrs.unshift(`src="${prefix}${imagePath}"`)
198
- } else {
199
- const outputDir = getOutputDir()
200
- const { src, variants } = discoverImageVariants(imagePath, outputDir)
201
- const sizes = kwargs.sizes || '100vw'
202
- attrs.unshift(`src="${prefix}${src}"`)
203
- if (variants.length > 0) {
204
- const srcsetVal = variants.map(v => `${prefix}${v.path} ${v.width}w`).join(', ')
205
- attrs.push(`srcset="${srcsetVal}"`)
206
- attrs.push(`sizes="${sizes}"`)
207
- }
208
- }
209
-
210
- attrs.push(`loading="${loading}"`)
211
- const skip = new Set(['alt', 'sizes', 'loading'])
212
- for (const [key, val] of Object.entries(kwargs)) {
213
- if (skip.has(key)) continue
214
- attrs.push(`${key}="${val}"`)
215
- }
216
- return `<img ${attrs.join(' ')}>`
205
+ return buildImageTag(imagePath, prefix, kwargs, getOutputDir)
217
206
  }
218
207
  })
219
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 } 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 })
@@ -46,7 +48,7 @@ class RelativeLoader extends nunjucks.Loader {
46
48
  }
47
49
 
48
50
  if (path.extname(fullPath) === '.md') {
49
- source = marked.parse(source)
51
+ source = decodeTemplateEntities(marked.parse(source))
50
52
  }
51
53
 
52
54
  if (frontMatter.layout) {
@@ -108,6 +110,15 @@ 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
+ })
121
+ env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
111
122
  env.addFilter('highlight', (code, lang) => {
112
123
  const highlighted = highlightCode(code, lang)
113
124
  const langClass = lang ? ` language-${lang}` : ''
@@ -125,6 +136,10 @@ export default class NunjucksEngine {
125
136
  this.env.addGlobal(key, value)
126
137
  }
127
138
 
139
+ removeGlobal(key) {
140
+ delete this.env.globals[key]
141
+ }
142
+
128
143
  render(templateName, context) {
129
144
  return new Promise((resolve, reject) => {
130
145
  this.env.getTemplate(templateName).render(context, (error, result) => {
@@ -152,7 +167,7 @@ export default class NunjucksEngine {
152
167
 
153
168
  // --- Nunjucks Extensions ---
154
169
 
155
- class GoogleFontsExtension {
170
+ export class GoogleFontsExtension {
156
171
  constructor() { this.tags = ['googleFonts'] }
157
172
 
158
173
  parse(parser, nodes) {
@@ -195,7 +210,7 @@ class GoogleFontsExtension {
195
210
  }
196
211
  }
197
212
 
198
- class ImageExtension {
213
+ export class ImageExtension {
199
214
  constructor(getOutputDir) { this.tags = ['image']; this.getOutputDir = getOutputDir }
200
215
 
201
216
  parse(parser, nodes) {
@@ -207,38 +222,11 @@ class ImageExtension {
207
222
 
208
223
  run(context, imagePath, kwargs) {
209
224
  const prefix = context.lookup('relativePathPrefix') || ''
210
- const alt = (kwargs && kwargs.alt) || ''
211
- const loading = (kwargs && kwargs.loading) || 'lazy'
212
- const isSvg = imagePath.endsWith('.svg')
213
- const attrs = [`alt="${alt}"`]
214
-
215
- if (isSvg) {
216
- attrs.unshift(`src="${prefix}${imagePath}"`)
217
- } else {
218
- const outputDir = this.getOutputDir()
219
- const { src, variants } = discoverImageVariants(imagePath, outputDir)
220
- const sizes = (kwargs && kwargs.sizes) || '100vw'
221
- attrs.unshift(`src="${prefix}${src}"`)
222
- if (variants.length > 0) {
223
- const srcsetVal = variants.map(v => `${prefix}${v.path} ${v.width}w`).join(', ')
224
- attrs.push(`srcset="${srcsetVal}"`)
225
- attrs.push(`sizes="${sizes}"`)
226
- }
227
- }
228
-
229
- attrs.push(`loading="${loading}"`)
230
- if (kwargs) {
231
- const skip = new Set(['alt', 'sizes', 'loading'])
232
- for (const [key, val] of Object.entries(kwargs)) {
233
- if (key.startsWith('__') || skip.has(key)) continue
234
- attrs.push(`${key}="${val}"`)
235
- }
236
- }
237
- return new nunjucks.runtime.SafeString(`<img ${attrs.join(' ')}>`)
225
+ return new nunjucks.runtime.SafeString(buildImageTag(imagePath, prefix, kwargs, this.getOutputDir))
238
226
  }
239
227
  }
240
228
 
241
- class HighlightExtension {
229
+ export class HighlightExtension {
242
230
  constructor() { this.tags = ['highlight'] }
243
231
 
244
232
  parse(parser, nodes) {
@@ -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
 
@@ -58,9 +60,139 @@ export function clearFrontMatterCache(filePath) {
58
60
  frontMatterCache.delete(filePath)
59
61
  }
60
62
 
63
+ export function wordcount(text) {
64
+ if (!text) return 0
65
+ // eslint-disable-next-line no-useless-escape
66
+ const stripped = text.replace(/<[^>]*>/g, ' ').replace(/[#*_`~\[\]()>|{}\\-]/g, ' ')
67
+ const words = stripped.match(/\S+/g)
68
+ return words ? words.length : 0
69
+ }
70
+
71
+ // marked HTML-encodes quotes/brackets in template tags it treats as prose,
72
+ // breaking string args like groupby("date"). Decode entities inside {{ }} / {% %}
73
+ // after markdown, before the template engine parses. Decode &amp; last so
74
+ // &amp;lt; doesn't double-decode into <.
75
+ export function decodeTemplateEntities(html) {
76
+ return html.replace(/(\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g, (tag) =>
77
+ tag
78
+ .replace(/&quot;/g, '"')
79
+ .replace(/&#39;/g, "'")
80
+ .replace(/&lt;/g, '<')
81
+ .replace(/&gt;/g, '>')
82
+ .replace(/&amp;/g, '&')
83
+ )
84
+ }
85
+
86
+ export function groupby(arr, key, datePart) {
87
+ if (!Array.isArray(arr)) return []
88
+
89
+ const map = new Map()
90
+ for (const item of arr) {
91
+ let value = item[key]
92
+ if (datePart && value) {
93
+ const date = new Date(value)
94
+ if (!isNaN(date)) {
95
+ switch (datePart) {
96
+ case 'year': value = date.getUTCFullYear(); break
97
+ case 'month': value = date.getUTCMonth() + 1; break
98
+ case 'day': value = date.getUTCDate(); break
99
+ }
100
+ }
101
+ }
102
+ const groupKey = value != null ? String(value) : ''
103
+ if (!map.has(groupKey)) map.set(groupKey, [])
104
+ map.get(groupKey).push(item)
105
+ }
106
+
107
+ return Array.from(map, ([key, items]) => ({ key, items }))
108
+ }
109
+
61
110
  const FORMAT_PRIORITY = ['avif', 'webp']
62
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
+
63
192
  export function discoverImageVariants(imagePath, outputDir) {
193
+ const fromCache = discoverImageVariantsFromCache(imagePath, outputDir)
194
+ if (fromCache) return fromCache
195
+
64
196
  const parsed = path.parse(imagePath)
65
197
  const dir = path.join(outputDir, parsed.dir)
66
198
  const baseName = parsed.name
@@ -81,44 +213,14 @@ export function discoverImageVariants(imagePath, outputDir) {
81
213
  const [, name, widthStr, format] = match
82
214
  if (name !== baseName) continue
83
215
  variants.push({
84
- path: path.join(parsed.dir, file),
216
+ path: toPosix(path.join(parsed.dir, file)),
85
217
  width: parseInt(widthStr, 10),
86
218
  format
87
219
  })
88
220
  }
89
221
 
90
- variants.sort((a, b) => a.width - b.width)
91
-
92
- // Pick best format for srcset: highest priority format that has variants
93
- const availableFormats = new Set(variants.map(v => v.format))
94
- let srcsetFormat = null
95
- for (const fmt of FORMAT_PRIORITY) {
96
- if (availableFormats.has(fmt)) {
97
- srcsetFormat = fmt
98
- break
99
- }
100
- }
101
- if (!srcsetFormat && availableFormats.has(originalExt)) {
102
- srcsetFormat = originalExt
103
- }
104
- if (!srcsetFormat && availableFormats.size > 0) {
105
- srcsetFormat = [...availableFormats][0]
106
- }
107
-
108
- const srcsetVariants = srcsetFormat ? variants.filter(v => v.format === srcsetFormat) : []
109
-
110
- // Pick middle-sized variant in original format for src fallback
111
- const originalVariants = variants.filter(v => v.format === originalExt)
112
- let src = imagePath
113
- if (originalVariants.length > 0) {
114
- const mid = Math.floor((originalVariants.length - 1) / 2)
115
- src = originalVariants[mid].path
116
- } else if (srcsetVariants.length > 0) {
117
- const mid = Math.floor((srcsetVariants.length - 1) / 2)
118
- src = srcsetVariants[mid].path
119
- }
120
-
121
- return { src, variants: srcsetVariants }
222
+ const { srcVariant, srcsetVariants } = pickSrcsetAndSrc(variants, originalExt)
223
+ return { src: srcVariant ? srcVariant.path : imagePath, variants: srcsetVariants }
122
224
  }
123
225
 
124
226
  export function replaceOutExtensions(outputPath) {
@@ -153,8 +255,9 @@ export function getRelativePathPrefix(outputDir, fromDir, baseURL) {
153
255
  return baseURL.endsWith('/') ? baseURL : baseURL + '/'
154
256
  }
155
257
 
156
- let relativeDir = path.relative(process.cwd(), outputDir)
157
- 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)) : ''
158
261
 
159
262
  if (fromRelativeDir && relativeDir.startsWith(fromRelativeDir)) {
160
263
  relativeDir = relativeDir.replace(fromRelativeDir, '')
@@ -165,10 +268,50 @@ export function getRelativePathPrefix(outputDir, fromDir, baseURL) {
165
268
 
166
269
  export function getPageUrl(outputPath) {
167
270
  outputPath = replaceOutExtensions(outputPath)
168
- 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))
169
272
  }
170
273
 
171
274
  export function getPageUrlRelativeToOutput(outputPath, outputDir) {
172
275
  const pageUrl = getPageUrl(outputPath)
173
- 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(' ')}>`
174
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
  }