poops 1.4.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -797,11 +797,25 @@ Output:
797
797
  - Defaults: `sizes="100vw"`, `loading="lazy"`
798
798
  - Falls back to a plain `<img src="...">` if no variants are found
799
799
 
800
+ **Named crops with `size`** — pass a `size` kwarg to build the `<img>` from a named crop/resize group instead of the default responsive widths. The whole group becomes its own srcset (each crop has its own aspect ratio), so a square thumbnail set, a wide banner set, etc. each get correct `srcset`/`width`/`height`:
801
+
802
+ ```nunjucks
803
+ {% image 'static/photo.jpg', size='thumb', alt='', sizes='240px' %}
804
+ ```
805
+
806
+ ```html
807
+ <img src="static/photo-thumb-480w.webp"
808
+ srcset="static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w"
809
+ width="480" height="480" sizes="240px" alt="" loading="lazy">
810
+ ```
811
+
812
+ This requires the [poops-images](https://github.com/stamat/poops-images) compile cache (named-size widths are read from it). The `size` name matches a named entry in your `images.sizes` config. The largest member of the group is written without a width suffix (`photo-thumb.webp`) — poops still srcsets it at its real width from the cache.
813
+
800
814
  **[poops-images](https://github.com/stamat/poops-images) integration:** if a `.poops-images-cache.json` compile cache is found in the output directory (poops-images writes one next to the images it generates), Poops reads variants from it instead of scanning the directory. On top of the scan behavior above, the cache gives you:
801
815
 
802
816
  - `width` and `height` attributes on the `<img>` element (exact dimensions from the cache — prevents layout shift). Pass your own `width`/`height` kwargs to override.
803
817
  - Correct `src` when the source format was converted (e.g. `photo.heic` → `photo.jpg`), even when there are no size variants.
804
- - Named sizes (`photo-thumb-200w.jpg`) and preprocessed outputs (`photo-blurred-640w.jpg`) are kept out of the srcset — they are crops and effects with their own aspect ratios. Only plain `{name}-{width}w.{ext}` variants (from the poops-images `widths` option) are used.
818
+ - By default the srcset is built only from the plain `{name}-{width}w.{ext}` width variants. Named sizes (`photo-thumb-480w.webp`) and preprocessed outputs (`photo-blurred-640w.jpg`) are kept out of it — they are crops and effects with their own aspect ratios. Reach a named crop group on purpose with the `size` kwarg above (or the `srcset` filter's second argument).
805
819
  - EXIF metadata via the `exif` filter (see below).
806
820
 
807
821
  ##### googleFonts
@@ -945,6 +959,8 @@ All filters are available in both engines. The only syntax difference is how arg
945
959
 
946
960
  Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w`
947
961
 
962
+ Pass a named crop/resize group as the second argument to get that group's srcset instead of the default widths: `{{ 'static/photo.jpg' | srcset: 'thumb' }}` → `static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w`.
963
+
948
964
  - `exif` — returns the EXIF metadata object for an image from the [poops-images](https://github.com/stamat/poops-images) compile cache (`.poops-images-cache.json` in the output directory), or `null` if there is no cache or no EXIF data. The object includes camera (`make`, `model`, `lensModel`), exposure (`fNumber`, `exposure.formatted`, `iso`, `focalLength35mm`), `dateTime`, and `gps` (`latitude.formatted`, `longitude.formatted`, `altitude`, and a ready-made `googleMapsUrl`).
949
965
 
950
966
  Example — a photo with date and location caption:
@@ -969,19 +985,21 @@ Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo
969
985
  - `path` — site-relative source path, feeds straight into the `{% image %}` tag
970
986
  - `date` — `exif.dateTime` when the photo has EXIF, file modification time otherwise — so sorting and grouping work for every image
971
987
  - `outputs` — every generated file for the image (site-relative), useful for picking LQIP or preprocessed variants
972
- - Pass a subdirectory (`'images/2025'`) to scope the list
988
+ - Pass a subdirectory (`'static/images/2025'`) to scope the list
989
+
990
+ **The path is relative to your markup `out` dir, not to `images.in`.** It mirrors where the generated images land — i.e. `images.out` made relative to markup `out`. So if `images.out` is `_site/static/images` and markup `out` is `_site`, the images live at `static/images` on the site and you call `'static/images' | images` (**not** `'images'`, which would look in `_site/images` and return `[]`). This is the same path you already pass to the `{% image %}` tag.
973
991
 
974
- Combined with `groupby`, engine-native sorting and the `{% image %}` tag, a photo gallery is a pure template concern:
992
+ Combined with `groupby`, engine-native sorting and the `{% image %}` tag, a photo gallery is a pure template concern. This is the Instagram-style square grid — `size='thumb'` pulls the named crop group and its auto-generated srcset (define a `thumb` crop in `images.sizes`):
975
993
 
976
994
  Nunjucks:
977
995
 
978
996
  ```nunjucks
979
- {% for group in 'images' | images | sort(reverse=true, attribute='date') | groupby("date", "year") %}
997
+ {% for group in 'static/images' | images | sort(reverse=true, attribute='date') | groupby("date", "year") %}
980
998
  <h2>{{ group.key }}</h2>
981
999
  <div class="grid">
982
1000
  {% for img in group.items %}
983
1001
  <figure>
984
- {% image img.path, alt='', sizes='(max-width: 640px) 50vw, 25vw' %}
1002
+ {% image img.path, size='thumb', alt='', sizes='(max-width: 640px) 50vw, 240px' %}
985
1003
  {% if img.exif and img.exif.gps %}
986
1004
  <figcaption>
987
1005
  <a href="{{ img.exif.gps.googleMapsUrl }}">📍</a> {{ img.date | date("MMM D, YYYY") }}
@@ -996,13 +1014,13 @@ Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo
996
1014
  Liquid:
997
1015
 
998
1016
  ```liquid
999
- {% assign imgs = 'images' | images | sort: 'date' | reverse %}
1017
+ {% assign imgs = 'static/images' | images | sort: 'date' | reverse %}
1000
1018
  {% assign groups = imgs | groupby: "date", "year" %}
1001
1019
  {% for group in groups %}
1002
1020
  <h2>{{ group.key }}</h2>
1003
1021
  <div class="grid">
1004
1022
  {% for img in group.items %}
1005
- <figure>{% image img.path, alt: '' %}</figure>
1023
+ <figure>{% image img.path, size: 'thumb', alt: '' %}</figure>
1006
1024
  {% endfor %}
1007
1025
  </div>
1008
1026
  {% endfor %}
@@ -45,7 +45,7 @@ export default class LiquidEngine {
45
45
  const engine = this.engine
46
46
  engine.registerFilter('slugify', (str) => slugify(str))
47
47
  engine.registerFilter('jsonify', (obj) => JSON.stringify(obj))
48
- engine.registerFilter('markdown', (str) => marked.parse(str))
48
+ engine.registerFilter('markdown', (str) => marked.parse(String(str || '')))
49
49
  engine.registerFilter('toc', (html) => renderToc(String(html || '')))
50
50
  engine.registerFilter('date', (str, template) => {
51
51
  const fmt = template || timeDateFormat
@@ -69,9 +69,9 @@ export default class LiquidEngine {
69
69
  if (!/^(<\?xml[^?]*\?>\s*)?<svg[\s>]/i.test(content)) return ''
70
70
  return content
71
71
  })
72
- engine.registerFilter('srcset', (imagePath) => {
72
+ engine.registerFilter('srcset', (imagePath, size) => {
73
73
  const outputDir = path.resolve(process.cwd(), markupOut)
74
- const { variants } = discoverImageVariants(imagePath, outputDir)
74
+ const { variants } = discoverImageVariants(imagePath, outputDir, size)
75
75
  if (variants.length === 0) return ''
76
76
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
77
77
  })
@@ -79,7 +79,7 @@ export default class NunjucksEngine {
79
79
  const env = this.env
80
80
  env.addFilter('slugify', slugify)
81
81
  env.addFilter('jsonify', (obj) => JSON.stringify(obj))
82
- env.addFilter('markdown', (str) => marked.parse(str))
82
+ env.addFilter('markdown', (str) => marked.parse(String(str || '')))
83
83
  env.addFilter('toc', (html) => {
84
84
  const toc = renderToc(String(html || ''))
85
85
  // plain '' (falsy) when there are no headings, so `{% if x | toc %}` can
@@ -108,9 +108,9 @@ export default class NunjucksEngine {
108
108
  const date = !str || str.trim() === '' ? new Date() : new Date(str)
109
109
  return dayjs(date).format(fmt)
110
110
  })
111
- env.addFilter('srcset', (imagePath) => {
111
+ env.addFilter('srcset', (imagePath, size) => {
112
112
  const outputDir = path.resolve(process.cwd(), markupOut)
113
- const { variants } = discoverImageVariants(imagePath, outputDir)
113
+ const { variants } = discoverImageVariants(imagePath, outputDir, size)
114
114
  if (variants.length === 0) return ''
115
115
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
116
116
  })
@@ -88,15 +88,20 @@ export function decodeTemplateEntities(html) {
88
88
  // H3s carry a `toc-h3` class for indentation — no nested <ul>, so leading H3s
89
89
  // (no parent H2) stay valid. Heading text is already entity-encoded by marked,
90
90
  // so it's spliced in as-is (re-escaping would double-encode &amp;).
91
+ // `.sr-only` headings are visually hidden, so they're skipped — a visible TOC
92
+ // linking to invisible content is confusing.
91
93
  export function renderToc(html) {
92
94
  if (!html) return ''
93
- const re = /<h([23])\b[^>]*\sid="([^"]*)"[^>]*>([\s\S]*?)<\/h\1>/gi
95
+ const re = /<h([23])\b([^>]*)>([\s\S]*?)<\/h\1>/gi
94
96
  let items = ''
95
97
  let match
96
98
  while ((match = re.exec(html)) !== null) {
99
+ const attrs = match[2]
100
+ if (/\bclass="[^"]*\bsr-only\b/i.test(attrs)) continue
101
+ const id = (attrs.match(/\sid="([^"]*)"/i) || [])[1]
97
102
  const text = match[3].replace(/<[^>]*>/g, '').trim()
98
- if (!match[2] || !text) continue
99
- items += `<li class="toc-h${match[1]}"><a href="#${match[2]}">${text}</a></li>`
103
+ if (!id || !text) continue
104
+ items += `<li class="toc-h${match[1]}"><a href="#${id}">${text}</a></li>`
100
105
  }
101
106
  if (!items) return ''
102
107
  return `<nav class="toc" aria-label="On this page"><ul>${items}</ul></nav>`
@@ -172,16 +177,32 @@ function pickSrcsetAndSrc(variants, originalExt) {
172
177
  }
173
178
 
174
179
  // Variant discovery from the poops-images compile cache: exact output paths
175
- // and dimensions, no directory scan. Only `{name}-{width}w.{ext}` outputs are
176
- // srcset material named sizes (`-thumb-200w`) and preprocessed outputs
177
- // (`-blurred-...`) are crops or effects with their own aspect ratios.
178
- function discoverImageVariantsFromCache(imagePath, outputDir) {
180
+ // and dimensions, no directory scan. By default only `{name}-{width}w.{ext}`
181
+ // outputs are srcset material. Pass `size` to instead build the srcset from a
182
+ // named crop/resize group (`thumb`) poops-images tags each named variant with
183
+ // its size name in the cache, and the largest of the group drops the width
184
+ // suffix (`{name}-thumb.{ext}`), so widths come from the cache, not the filename.
185
+ function discoverImageVariantsFromCache(imagePath, outputDir, size) {
179
186
  const found = getImageEntry(imagePath, outputDir)
180
187
  if (!found) return null
181
188
 
182
189
  const { entry, prefixDir } = found
183
190
  const parsed = path.parse(imagePath)
184
191
  const originalExt = parsed.ext.replace('.', '')
192
+ const sitePath = (p) => prefixDir ? toPosix(path.join(prefixDir, p)) : toPosix(p)
193
+ const extOf = (p) => path.posix.extname(toPosix(p)).replace('.', '')
194
+
195
+ if (size) {
196
+ // Named group — every output tagged with this size name; widths from the cache
197
+ const variants = (entry.outputs || [])
198
+ .filter(o => o.name === size && o.width)
199
+ .map(o => ({ path: sitePath(o.path), width: o.width, height: o.height, format: extOf(o.path) }))
200
+ if (variants.length === 0) return null
201
+ const { srcVariant, srcsetVariants } = pickSrcsetAndSrc(variants, originalExt)
202
+ const src = srcVariant || srcsetVariants[srcsetVariants.length - 1] || variants[variants.length - 1]
203
+ return { src: src.path, variants: srcsetVariants, width: src.width, height: src.height }
204
+ }
205
+
185
206
  const variantPattern = new RegExp(`^${escapeRegExp(parsed.name)}-(\\d+)w\\.([a-z0-9]+)$`)
186
207
  const basePattern = new RegExp(`^${escapeRegExp(parsed.name)}\\.([a-z0-9]+)$`)
187
208
 
@@ -189,15 +210,17 @@ function discoverImageVariantsFromCache(imagePath, outputDir) {
189
210
  let base = null
190
211
  for (const out of entry.outputs || []) {
191
212
  const file = path.posix.basename(toPosix(out.path))
192
- const sitePath = prefixDir ? toPosix(path.join(prefixDir, out.path)) : toPosix(out.path)
213
+ // Named / preprocessed variants (they carry a `name`) are crops or effects
214
+ // with their own aspect ratio — never mix them into the default width srcset.
215
+ if (out.name) continue
193
216
  let match = file.match(variantPattern)
194
217
  if (match) {
195
- variants.push({ path: sitePath, width: parseInt(match[1], 10), height: out.height, format: match[2] })
218
+ variants.push({ path: sitePath(out.path), width: parseInt(match[1], 10), height: out.height, format: match[2] })
196
219
  continue
197
220
  }
198
221
  match = file.match(basePattern)
199
222
  if (match && !base) {
200
- base = { path: sitePath, width: out.width, height: out.height }
223
+ base = { path: sitePath(out.path), width: out.width, height: out.height }
201
224
  }
202
225
  }
203
226
 
@@ -208,9 +231,11 @@ function discoverImageVariantsFromCache(imagePath, outputDir) {
208
231
  return { src: src.path, variants: srcsetVariants, width: src.width, height: src.height }
209
232
  }
210
233
 
211
- export function discoverImageVariants(imagePath, outputDir) {
212
- const fromCache = discoverImageVariantsFromCache(imagePath, outputDir)
234
+ export function discoverImageVariants(imagePath, outputDir, size) {
235
+ const fromCache = discoverImageVariantsFromCache(imagePath, outputDir, size)
213
236
  if (fromCache) return fromCache
237
+ // A named size only exists in the compile cache — no directory-scan fallback.
238
+ if (size) return { src: imagePath, variants: [] }
214
239
 
215
240
  const parsed = path.parse(imagePath)
216
241
  const dir = path.join(outputDir, parsed.dir)
@@ -322,7 +347,7 @@ export function buildImageTag(imagePath, prefix, kwargs, getOutputDir) {
322
347
  if (isSvg) {
323
348
  attrs.unshift(`src="${escapeAttr(prefix + imagePath)}"`)
324
349
  } else {
325
- const { src, variants, width, height } = discoverImageVariants(imagePath, getOutputDir())
350
+ const { src, variants, width, height } = discoverImageVariants(imagePath, getOutputDir(), kwargs && kwargs.size)
326
351
  const sizes = (kwargs && kwargs.sizes) || '100vw'
327
352
  attrs.unshift(`src="${escapeAttr(prefix + src)}"`)
328
353
  if (width && height && !(kwargs && (kwargs.width || kwargs.height))) {
@@ -337,7 +362,7 @@ export function buildImageTag(imagePath, prefix, kwargs, getOutputDir) {
337
362
 
338
363
  attrs.push(`loading="${escapeAttr(loading)}"`)
339
364
  if (kwargs) {
340
- const skip = new Set(['alt', 'sizes', 'loading'])
365
+ const skip = new Set(['alt', 'sizes', 'loading', 'size'])
341
366
  for (const [key, val] of Object.entries(kwargs)) {
342
367
  // `__keywords` is nunjucks' kwargs marker, never a real attribute
343
368
  if (key.startsWith('__') || skip.has(key)) continue
@@ -69,8 +69,8 @@ export function highlightCode(code, language) {
69
69
  }
70
70
 
71
71
  export const highlightRenderer = {
72
- code(code, lang) {
73
- const highlighted = highlightCode(code, lang)
72
+ code({ text, lang }) {
73
+ const highlighted = highlightCode(text, lang)
74
74
  const langClass = lang ? ` language-${escapeHtml(lang)}` : ''
75
75
  return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>\n`
76
76
  }
@@ -1,6 +1,7 @@
1
1
  import { Marked } from 'marked'
2
2
  import { markedGithubEmoji } from 'marked-github-emoji'
3
3
  import { markedGithubAlerts } from 'marked-github-alerts'
4
+ import { markedGithubFootnote } from 'marked-github-footnote'
4
5
  import { slugify } from 'book-of-spells'
5
6
  import { highlightRenderer, highlightCode } from './highlight.js'
6
7
  import { decodeTemplateEntities } from './helpers.js'
@@ -33,8 +34,8 @@ export const markdownRenderer = {
33
34
  // as empty). Highlight around/inside each raw block instead, keeping the
34
35
  // fence's language, and pass the delimiters through intact. Inline code
35
36
  // never splits the delimiters, so it needs no special casing.
36
- code(code, lang) {
37
- const highlighted = mapRawSegments(code, (s) => s && highlightCode(s, lang), (s) => highlightCode(s, lang))
37
+ code({ text, lang }) {
38
+ const highlighted = mapRawSegments(text, (s) => s && highlightCode(s, lang), (s) => highlightCode(s, lang))
38
39
  const langClass = lang ? ` language-${lang.replace(/[^\w-]/g, '')}` : ''
39
40
  return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>\n`
40
41
  },
@@ -43,10 +44,11 @@ export const markdownRenderer = {
43
44
  // no such CSS renders an invisible anchor instead of a stray "#".
44
45
  // ponytail: no slug dedup — two identical headings on one page share an id;
45
46
  // add a per-parse counter if that ever bites.
46
- heading(text, level, raw) {
47
+ heading({ tokens, depth, text: raw }) {
48
+ const text = this.parser.parseInline(tokens)
47
49
  const id = slugify(raw || '')
48
- if (!id) return `<h${level}>${text}</h${level}>\n`
49
- return `<h${level} id="${id}">${text}<a class="heading-anchor" href="#${id}" aria-label="Permalink" aria-hidden="true"></a></h${level}>\n`
50
+ if (!id) return `<h${depth}>${text}</h${depth}>\n`
51
+ return `<h${depth} id="${id}">${text}<a class="heading-anchor" href="#${id}" aria-label="Permalink" aria-hidden="true"></a></h${depth}>\n`
50
52
  }
51
53
  }
52
54
 
@@ -58,6 +60,7 @@ marked.use(markedGithubAlerts({
58
60
  info: { title: 'Info', icon: 'info' }
59
61
  }
60
62
  }))
63
+ marked.use(markedGithubFootnote())
61
64
 
62
65
  // Renders a markdown page source for the template engines. Entity decoding
63
66
  // (which un-escapes inside {{ }} / {% %} so template args parse) must skip
package/lib/markups.js CHANGED
@@ -223,6 +223,7 @@ export default class Markups {
223
223
  try {
224
224
  const frontMatterResult = parseFrontMatter(templateName)
225
225
  context.page = frontMatterResult.frontMatter
226
+ context.page.content = frontMatterResult.content
226
227
  context.page.wordcount = wordcount(frontMatterResult.content)
227
228
  } catch (err) {
228
229
  log({ tag: 'error', text: 'Failed parsing front matter:', link: templateName })
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "poops",
3
3
  "description": "Straightforward, no-bullshit bundler for the web.",
4
- "version": "1.4.0",
4
+ "version": "1.5.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
@@ -34,8 +34,8 @@
34
34
  "static-site-generator"
35
35
  ],
36
36
  "peerDependencies": {
37
- "postcss": "^8.0.0",
38
- "poops-images": ">=1.2.0"
37
+ "poops-images": ">=1.2.1",
38
+ "postcss": "^8.0.0"
39
39
  },
40
40
  "peerDependenciesMeta": {
41
41
  "postcss": {
@@ -61,9 +61,10 @@
61
61
  "highlight.js": "^11.11.1",
62
62
  "liquidjs": "^10.24.0",
63
63
  "livereload": "^0.9.3",
64
- "marked": "^9.0.3",
64
+ "marked": "^18.0.6",
65
65
  "marked-github-alerts": "^1.0.1",
66
66
  "marked-github-emoji": "^1.0.2",
67
+ "marked-github-footnote": "^1.0.0",
67
68
  "nunjucks": "^3.2.4",
68
69
  "portscanner": "^2.2.0",
69
70
  "printstyle": "^1.0.0",
@@ -88,4 +89,4 @@
88
89
  "overrides": {
89
90
  "minimatch": ">=10.2.1"
90
91
  }
91
- }
92
+ }