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.
- package/README.md +239 -65
- package/lib/copy.js +4 -2
- package/lib/images.js +57 -0
- package/lib/markup/collections.js +34 -13
- package/lib/markup/engines/liquid.js +15 -27
- package/lib/markup/engines/nunjucks.js +17 -30
- package/lib/markup/helpers.js +133 -37
- package/lib/markup/image-cache.js +114 -0
- package/lib/markup/indexer.js +6 -2
- package/lib/markups.js +54 -10
- package/lib/postcss.js +16 -30
- package/lib/reactor.js +11 -29
- package/lib/scripts.js +37 -34
- package/lib/styles.js +17 -31
- package/lib/utils/helpers.js +20 -16
- package/lib/utils/log.js +15 -1
- package/lib/utils/minify.js +41 -0
- package/package.json +7 -3
- package/poops.js +106 -27
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/markup/helpers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|
package/lib/markup/indexer.js
CHANGED
|
@@ -116,7 +116,9 @@ export function generateSearchIndex(pageEntries, outputDir, config) {
|
|
|
116
116
|
|
|
117
117
|
entries = applyGlobalFrequencyCeiling(entries, config.globalFrequencyCeiling)
|
|
118
118
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
75
|
-
this.loadDataFiles(
|
|
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
|
-
|
|
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.
|
|
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
|
}
|