poops 1.2.1 → 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
@@ -507,6 +523,7 @@ Then use Tailwind utility classes directly in your markup templates. Tailwind v4
507
523
  - `site` (optional) - global data that will be available to all templates in the markup directory. Like site title, description, social media links, etc. You can then use this data in your templates `{{ site.title }}` for instance.
508
524
  - `data` (optional) - is an array of JSON or YAML data files, that once loaded will be available to all templates in the markup directory. If you provide a path to a file for instance `links.json` with a `facebook` property, you can then use this data in your templates `{{ links.facebook }}`. The base name of the file will be used as the variable name, with spaces, dashes and dots replaced with underscores. So `the awesome-links.json` will be available as `{{ the_awesome_links.facebook }}` in your templates. The root directory of the data files is `in` directory. So if you have a `data` directory in your `in` directory, you can specify the data files like this `data: ["data/links.json"]`. The same goes for the YAML files.
509
525
  - `includePaths` - an array of paths to directories that will be added to the template engine's include paths. Useful if you want to separate template partials and layouts. For instance, if you have a `_includes` directory with a `header.njk` (or `header.liquid`) partial that you want to include in your markup, you can add it to the include paths and then include the templates like this `{% include "header.njk" %}`, without specifying the full path to the partial.
526
+ - `baseURL` (optional) - a base URL prefix to use instead of relative path prefixes. When set, `{{ relativePathPrefix }}` will always resolve to this value (with a trailing slash ensured) instead of being computed relative to each page's depth. Useful when deploying under a subdirectory (e.g. `"/blog"` for `domain.com/blog/`). When not set, relative prefixes (`./`, `../`, etc.) are used, which work for any deployment location including subdirectories and `file://` URLs.
510
527
 
511
528
  **💡 NOTE:** If, for instance, you are building a simple static onepager for your library, and want to pass a version variable from your `package.json`, Poops automatically reads your `package.json` if it exists in your working directory and sets the global variable `package` to the parsed JSON. So you can use it in your markup files, for example like this: `{{ package.version }}`.
512
529
 
@@ -527,7 +544,8 @@ Here is a sample markup configuration using the default Nunjucks engine:
527
544
  ],
528
545
  "includePaths": [
529
546
  "_includes"
530
- ]
547
+ ],
548
+ "baseURL": "/blog"
531
549
  }
532
550
  }
533
551
  ```
@@ -726,6 +744,21 @@ All filters are available in both engines. The only syntax difference is how arg
726
744
  - Nunjucks: `{{ someCodeVariable | highlight('javascript') }}`
727
745
  - Liquid: `{{ someCodeVariable | highlight: 'javascript' }}`
728
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
+
729
762
  - `srcset` — returns just the srcset attribute value:
730
763
 
731
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
@@ -182,7 +185,7 @@ export function getCollectionIndexFile(markupInDir, collectionName) {
182
185
  return indexFiles[0]
183
186
  }
184
187
 
185
- export function generateCollectionPaginationPages(collectionData, markupInDir, markupOutDir, compileEntryFn) {
188
+ export function generateCollectionPaginationPages(collectionData, markupInDir, markupOutDir, compileEntryFn, baseURL) {
186
189
  if (!collectionData) return []
187
190
 
188
191
  const compilePromises = []
@@ -201,7 +204,7 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
201
204
  const pageUrl = pageNumber === 1 ? collection.name : `${collection.name}/${pageNumber}`
202
205
  const nextPage = pageNumber === collection.totalPages ? null : pageNumber + 1
203
206
  const nextPageUrl = pageNumber === collection.totalPages ? null : `${collection.name}/${pageNumber + 1}`
204
- let prevPage = pageNumber === 1 ? null : pageNumber - 1
207
+ const prevPage = pageNumber === 1 ? null : pageNumber - 1
205
208
  let prevPageUrl = pageNumber === 1 ? null : `${collection.name}/${pageNumber - 1}`
206
209
  if (prevPage === 1) {
207
210
  prevPageUrl = collection.name
@@ -228,7 +231,7 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
228
231
  const context = {
229
232
  ...collectionData,
230
233
  [collectionName]: pageSnapshot,
231
- relativePathPrefix: getRelativePathPrefix(markupOutDirFull, fromPath),
234
+ relativePathPrefix: getRelativePathPrefix(markupOutDirFull, fromPath, baseURL),
232
235
  _url: getPageUrl(markupOut)
233
236
  }
234
237
 
@@ -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) {
@@ -148,7 +195,11 @@ export function getUpDirPrefix(relativeDir) {
148
195
  return upDir
149
196
  }
150
197
 
151
- export function getRelativePathPrefix(outputDir, fromDir) {
198
+ export function getRelativePathPrefix(outputDir, fromDir, baseURL) {
199
+ if (baseURL != null) {
200
+ return baseURL.endsWith('/') ? baseURL : baseURL + '/'
201
+ }
202
+
152
203
  let relativeDir = path.relative(process.cwd(), outputDir)
153
204
  const fromRelativeDir = fromDir ? path.relative(process.cwd(), fromDir) : ''
154
205
 
@@ -156,7 +207,7 @@ export function getRelativePathPrefix(outputDir, fromDir) {
156
207
  relativeDir = relativeDir.replace(fromRelativeDir, '')
157
208
  }
158
209
 
159
- return getUpDirPrefix(relativeDir)
210
+ return getUpDirPrefix(relativeDir) || './'
160
211
  }
161
212
 
162
213
  export function getPageUrl(outputPath) {
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'
@@ -35,6 +35,7 @@ export default class Markups {
35
35
  this.includePaths = moduleConfig.includePaths || moduleConfig.options.includePaths || []
36
36
  this.searchIndexConfig = moduleConfig.options.searchIndex || moduleConfig.searchIndex
37
37
  this.sitemapConfig = moduleConfig.options.sitemap || moduleConfig.sitemap
38
+ this.baseURL = moduleConfig.baseURL || moduleConfig.options.baseURL || null
38
39
  this.dataFiles = []
39
40
 
40
41
  // Instantiate engine
@@ -151,6 +152,7 @@ export default class Markups {
151
152
  try {
152
153
  const frontMatterResult = parseFrontMatter(templateName)
153
154
  context.page = frontMatterResult.frontMatter
155
+ context.page.wordcount = wordcount(frontMatterResult.content)
154
156
  } catch (err) {
155
157
  log({ tag: 'error', text: 'Failed parsing front matter:', link: templateName })
156
158
  console.error(err)
@@ -196,7 +198,7 @@ export default class Markups {
196
198
 
197
199
  const fileContext = {
198
200
  ...collectionData,
199
- relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath),
201
+ relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
200
202
  _url: getPageUrl(markupOut)
201
203
  }
202
204
 
@@ -254,7 +256,7 @@ export default class Markups {
254
256
 
255
257
  const fileContext = {
256
258
  ...collectionData,
257
- relativePathPrefix: getRelativePathPrefix(markupOutDir),
259
+ relativePathPrefix: getRelativePathPrefix(markupOutDir, null, this.baseURL),
258
260
  _url: getPageUrl(markupOut)
259
261
  }
260
262
 
@@ -321,7 +323,7 @@ export default class Markups {
321
323
  const pageEntries = shouldIndex ? [] : null
322
324
 
323
325
  buildCollectionPaginationData(collectionData)
324
- const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this))
326
+ const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL)
325
327
 
326
328
  await Promise.all(collectionPromises)
327
329
 
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.1",
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')