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.
- package/README.md +263 -58
- package/lib/copy.js +4 -2
- package/lib/images.js +57 -0
- package/lib/markup/collections.js +38 -14
- package/lib/markup/engines/liquid.js +17 -28
- package/lib/markup/engines/nunjucks.js +22 -34
- package/lib/markup/helpers.js +180 -37
- package/lib/markup/image-cache.js +114 -0
- package/lib/markup/indexer.js +6 -2
- package/lib/markups.js +56 -11
- 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 +114 -23
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
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
|
|
|
@@ -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 & last so
|
|
74
|
+
// &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(/"/g, '"')
|
|
79
|
+
.replace(/'/g, "'")
|
|
80
|
+
.replace(/</g, '<')
|
|
81
|
+
.replace(/>/g, '>')
|
|
82
|
+
.replace(/&/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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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, '&').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(' ')}>`
|
|
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
|
+
}
|
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
|
}
|