poops 1.2.2 → 1.2.3

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
@@ -68,6 +68,22 @@ or pass a custom config. This is useful when you have multiple environments:
68
68
 
69
69
  `poops yourAwesomeConfig.json` or `💩 yourAwesomeConfig.json`
70
70
 
71
+ **CLI Options:**
72
+
73
+ | Flag | Short | Description |
74
+ |------|-------|-------------|
75
+ | `--build` | `-b` | Build the project and exit |
76
+ | `--config <path>` | `-c` | Specify the config file |
77
+ | `--port <number>` | `-p` | Specify the server port, overrides config |
78
+ | `--livereload-port <number>` | `-l` | Specify the livereload port, overrides config |
79
+ | `--base-url <path>` | `-u` | Set the base URL prefix for markup, overrides config |
80
+
81
+ The `--base-url` flag is particularly useful for CI/CD pipelines where the deploy path may differ per environment:
82
+
83
+ ```bash
84
+ poops --build --base-url /blog
85
+ ```
86
+
71
87
  If you have installed Poops locally you can run it with `npx poops` or `npx 💩` or add a script to your `package.json`:
72
88
 
73
89
  ```json
@@ -728,6 +744,21 @@ All filters are available in both engines. The only syntax difference is how arg
728
744
  - Nunjucks: `{{ someCodeVariable | highlight('javascript') }}`
729
745
  - Liquid: `{{ someCodeVariable | highlight: 'javascript' }}`
730
746
 
747
+ - `groupby` — groups an array of objects by a field value. Returns an array of `{ key, items }` objects. Supports an optional second argument for date part extraction (`year`, `month`, `day`). Groups preserve insertion order, so if items are sorted by date descending, groups will be too.
748
+ - Nunjucks: `{{ changelog.items | groupby("author") }}` or `{{ changelog.items | groupby("date", "year") }}`
749
+ - Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
750
+
751
+ Example — group posts by year:
752
+ ```nunjucks
753
+ {% set byYear = changelog.items | groupby("date", "year") %}
754
+ {% for group in byYear %}
755
+ <h2>{{ group.key }}</h2>
756
+ {% for post in group.items %}
757
+ <p>{{ post.title }}</p>
758
+ {% endfor %}
759
+ {% endfor %}
760
+ ```
761
+
731
762
  - `srcset` — returns just the srcset attribute value:
732
763
 
733
764
  ```html
@@ -3,16 +3,18 @@ import { globSync } from 'glob'
3
3
  import path from 'node:path'
4
4
  import log from '../utils/log.js'
5
5
  import { mkDir } from '../utils/helpers.js'
6
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, parseFrontMatter } from './helpers.js'
6
+ import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, parseFrontMatter, wordcount } from './helpers.js'
7
7
 
8
8
  export function getSingleCollectionData(markupInDir, collectionName) {
9
9
  const collectionData = []
10
10
  globSync(path.join(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)'), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
11
11
  let frontMatter = {}
12
12
 
13
+ let content = ''
13
14
  try {
14
15
  const frontMatterResult = parseFrontMatter(file)
15
16
  frontMatter = frontMatterResult.frontMatter
17
+ content = frontMatterResult.content
16
18
  } catch (err) {
17
19
  log({ tag: 'error', text: 'Failed parsing front matter:', link: file })
18
20
  console.error(err)
@@ -23,6 +25,7 @@ export function getSingleCollectionData(markupInDir, collectionName) {
23
25
  if (!frontMatter.date) {
24
26
  frontMatter.date = fs.statSync(file).ctime.toISOString().slice(0, 16)
25
27
  }
28
+ frontMatter.wordcount = wordcount(content)
26
29
  frontMatter.fileName = path.basename(file)
27
30
  frontMatter.filePath = path.relative(process.cwd(), file)
28
31
  frontMatter.collection = collectionName
@@ -3,7 +3,7 @@ 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 } from '../helpers.js'
7
7
  import { slugify } from 'book-of-spells'
8
8
  import dayjs from 'dayjs'
9
9
 
@@ -75,6 +75,7 @@ export default class LiquidEngine {
75
75
  if (variants.length === 0) return ''
76
76
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
77
77
  })
78
+ engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
78
79
  engine.registerFilter('highlight', (code, lang) => {
79
80
  const highlighted = highlightCode(code, lang)
80
81
  const langClass = lang ? ` language-${lang}` : ''
@@ -98,7 +99,7 @@ export default class LiquidEngine {
98
99
  source = frontMatterResult.content
99
100
 
100
101
  if (path.extname(templateName) === '.md') {
101
- source = marked.parse(source)
102
+ source = decodeTemplateEntities(marked.parse(source))
102
103
  }
103
104
 
104
105
  const frontMatter = context.page || {}
@@ -3,7 +3,7 @@ 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 } from '../helpers.js'
7
7
  import { highlightRenderer, highlightCode } from '../highlight.js'
8
8
  import { slugify } from 'book-of-spells'
9
9
  import dayjs from 'dayjs'
@@ -46,7 +46,7 @@ class RelativeLoader extends nunjucks.Loader {
46
46
  }
47
47
 
48
48
  if (path.extname(fullPath) === '.md') {
49
- source = marked.parse(source)
49
+ source = decodeTemplateEntities(marked.parse(source))
50
50
  }
51
51
 
52
52
  if (frontMatter.layout) {
@@ -108,6 +108,7 @@ export default class NunjucksEngine {
108
108
  if (variants.length === 0) return ''
109
109
  return variants.map(v => `${v.path} ${v.width}w`).join(', ')
110
110
  })
111
+ env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
111
112
  env.addFilter('highlight', (code, lang) => {
112
113
  const highlighted = highlightCode(code, lang)
113
114
  const langClass = lang ? ` language-${lang}` : ''
@@ -152,7 +153,7 @@ export default class NunjucksEngine {
152
153
 
153
154
  // --- Nunjucks Extensions ---
154
155
 
155
- class GoogleFontsExtension {
156
+ export class GoogleFontsExtension {
156
157
  constructor() { this.tags = ['googleFonts'] }
157
158
 
158
159
  parse(parser, nodes) {
@@ -195,7 +196,7 @@ class GoogleFontsExtension {
195
196
  }
196
197
  }
197
198
 
198
- class ImageExtension {
199
+ export class ImageExtension {
199
200
  constructor(getOutputDir) { this.tags = ['image']; this.getOutputDir = getOutputDir }
200
201
 
201
202
  parse(parser, nodes) {
@@ -238,7 +239,7 @@ class ImageExtension {
238
239
  }
239
240
  }
240
241
 
241
- class HighlightExtension {
242
+ export class HighlightExtension {
242
243
  constructor() { this.tags = ['highlight'] }
243
244
 
244
245
  parse(parser, nodes) {
@@ -58,6 +58,53 @@ export function clearFrontMatterCache(filePath) {
58
58
  frontMatterCache.delete(filePath)
59
59
  }
60
60
 
61
+ export function wordcount(text) {
62
+ if (!text) return 0
63
+ // eslint-disable-next-line no-useless-escape
64
+ const stripped = text.replace(/<[^>]*>/g, ' ').replace(/[#*_`~\[\]()>|{}\\-]/g, ' ')
65
+ const words = stripped.match(/\S+/g)
66
+ return words ? words.length : 0
67
+ }
68
+
69
+ // marked HTML-encodes quotes/brackets in template tags it treats as prose,
70
+ // breaking string args like groupby("date"). Decode entities inside {{ }} / {% %}
71
+ // after markdown, before the template engine parses. Decode &amp; last so
72
+ // &amp;lt; doesn't double-decode into <.
73
+ export function decodeTemplateEntities(html) {
74
+ return html.replace(/(\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g, (tag) =>
75
+ tag
76
+ .replace(/&quot;/g, '"')
77
+ .replace(/&#39;/g, "'")
78
+ .replace(/&lt;/g, '<')
79
+ .replace(/&gt;/g, '>')
80
+ .replace(/&amp;/g, '&')
81
+ )
82
+ }
83
+
84
+ export function groupby(arr, key, datePart) {
85
+ if (!Array.isArray(arr)) return []
86
+
87
+ const map = new Map()
88
+ for (const item of arr) {
89
+ let value = item[key]
90
+ if (datePart && value) {
91
+ const date = new Date(value)
92
+ if (!isNaN(date)) {
93
+ switch (datePart) {
94
+ case 'year': value = date.getUTCFullYear(); break
95
+ case 'month': value = date.getUTCMonth() + 1; break
96
+ case 'day': value = date.getUTCDate(); break
97
+ }
98
+ }
99
+ }
100
+ const groupKey = value != null ? String(value) : ''
101
+ if (!map.has(groupKey)) map.set(groupKey, [])
102
+ map.get(groupKey).push(item)
103
+ }
104
+
105
+ return Array.from(map, ([key, items]) => ({ key, items }))
106
+ }
107
+
61
108
  const FORMAT_PRIORITY = ['avif', 'webp']
62
109
 
63
110
  export function discoverImageVariants(imagePath, outputDir) {
package/lib/markups.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime } from './utils/helpers.js'
2
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache } from './markup/helpers.js'
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'
5
5
  import NunjucksEngine from './markup/engines/nunjucks.js'
@@ -152,6 +152,7 @@ export default class Markups {
152
152
  try {
153
153
  const frontMatterResult = parseFrontMatter(templateName)
154
154
  context.page = frontMatterResult.frontMatter
155
+ context.page.wordcount = wordcount(frontMatterResult.content)
155
156
  } catch (err) {
156
157
  log({ tag: 'error', text: 'Failed parsing front matter:', link: templateName })
157
158
  console.error(err)
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.2.2",
4
+ "version": "1.2.3",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
package/poops.js CHANGED
@@ -28,6 +28,7 @@ const cli = new Argoyle(pkg.version)
28
28
  .option('config', { short: 'c', value: '<path>', description: 'Specify the config file' })
29
29
  .option('port', { short: 'p', value: '<number>', description: 'Specify the port for the server, overrides the config file' })
30
30
  .option('livereload-port', { short: 'l', value: '<number>', description: 'Specify the port for the livereload server, overrides the config file' })
31
+ .option('base-url', { short: 'u', value: '<path>', description: 'Set the base URL prefix for markup, overrides the config file' })
31
32
 
32
33
  let flags, positionals
33
34
  try {
@@ -41,6 +42,7 @@ const build = flags.build
41
42
  const defaultConfigPath = flags.config || positionals[0] || 'poops.json'
42
43
  const overridePort = flags.port
43
44
  const overrideLivereloadPort = flags['livereload-port']
45
+ const overrideBaseURL = flags['base-url']
44
46
 
45
47
  let configPath = path.join(cwd, defaultConfigPath)
46
48
  if (!pathExists(configPath)) configPath = path.join(cwd, '💩.json') // TODO: Ok dude, I know it's late, but you can do better than this.
@@ -82,7 +84,13 @@ function setupWatchers(config, modules) {
82
84
 
83
85
  // TODO: think about watching the updates of the config file itself, we can reload the config and recompile everything.
84
86
  // TODO: ability to automatically create a watch list of directories if watch is set to true. The list will be generated from the `in` property of each task.
85
- chokidar.watch(config.watch, { ignoreInitial: true }).on('change', (file) => {
87
+ // awaitWriteFinish: wait for saves to finish writing before recompiling, so a
88
+ // mid-write (truncated/partial) file is never read. Fixes intermittent broken
89
+ // builds on editor save. ponytail: default thresholds are fine; bump if slow disks flake.
90
+ chokidar.watch(config.watch, {
91
+ ignoreInitial: true,
92
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
93
+ }).on('change', (file) => {
86
94
  if (/(\.m?jsx?|\.tsx?)$/i.test(file)) {
87
95
  modules.scripts.compile().catch(err => console.error(err))
88
96
 
@@ -179,6 +187,10 @@ if (!config.reactor && config.ssg) {
179
187
  config.reactor = config.ssg
180
188
  }
181
189
 
190
+ if (overrideBaseURL && config.markup) {
191
+ config.markup.baseURL = overrideBaseURL
192
+ }
193
+
182
194
  async function getAvailablePort(port, max) {
183
195
  while (port < max) {
184
196
  const status = await portscanner.checkPortStatus(port, 'localhost')