poops 1.3.0 → 1.4.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
@@ -24,6 +24,37 @@ Intuitive with a minimal learning curve and minimal docs, utilizing the most eff
24
24
 
25
25
  It uses a simple config file where you define your input and output paths and it poops out your bundled files. Simple as that.
26
26
 
27
+ ## Table of Contents
28
+
29
+ - [Features](#features)
30
+ - [Quick Start](#quick-start)
31
+ - [Configuration](#configuration)
32
+ - [Scripts](#scripts)
33
+ - [JSX/TSX (React) Example](#jsxtsx-react-example)
34
+ - [Reactor (React Pre-rendering)](#reactor-react-pre-rendering)
35
+ - [Styles](#styles)
36
+ - [Design Tokens](#design-tokens)
37
+ - [PostCSS (optional)](#postcss-optional)
38
+ - [Tailwind CSS Example](#tailwind-css-example)
39
+ - [Markups](#markups)
40
+ - [Nunjucks vs Liquid](#nunjucks-vs-liquid)
41
+ - [Custom Engines](#custom-engines)
42
+ - [Collections & Pagination](#collections--pagination)
43
+ - [Custom Tags](#custom-tags)
44
+ - [image](#image)
45
+ - [googleFonts](#googlefonts)
46
+ - [highlight](#highlight)
47
+ - [Custom Filters](#custom-filters)
48
+ - [Search Index, Sitemap & Navigation](#search-index-sitemap--navigation)
49
+ - [Images (optional)](#images-optional)
50
+ - [Copy](#copy)
51
+ - [Banner (optional)](#banner-optional)
52
+ - [Local Server (optional)](#local-server-optional)
53
+ - [Live Reload (optional)](#live-reload-optional)
54
+ - [Watch (optional)](#watch-optional)
55
+ - [Include Paths (optional)](#include-paths-optional)
56
+ - [Why?](#why)
57
+
27
58
  ## Features
28
59
 
29
60
  - Bundles SCSS/SASS to CSS
@@ -174,8 +205,8 @@ You can freely remove the properties that you don't need. For example, if you do
174
205
 
175
206
  Scripts are bundled with [esbuild](https://esbuild.github.io/). Supports `.js`, `.ts`, `.jsx`, and `.tsx` files out of the box — including React and other JSX frameworks. You can specify multiple scripts to bundle. Each script has the following properties:
176
207
 
177
- - `in` - the input path, can be an array of file paths, but please just use one file path per script
178
- - `out` - the output path, can be a directory or a file path, but please just use it as a filename
208
+ - `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/js/*.js"`). Globs must use `/` separators (even on Windows)
209
+ - `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — entry points from different directories nest their output under the common ancestor (esbuild's `outbase`)
179
210
  - `options` - the options for the bundler. You can apply most of the esbuild options that are not in conflict with Poops. See [esbuild's options](https://esbuild.github.io/api/#build-api) for more info.
180
211
 
181
212
  **Options:**
@@ -308,8 +339,8 @@ Poops does not need `react` or `react-dom` as its own dependency — they are re
308
339
 
309
340
  Styles are bundled with [Dart Sass](https://sass-lang.com/dart-sass). You can specify multiple styles to bundle. Each style has the following properties:
310
341
 
311
- - `in` - the input path, accepts only a path to a file
312
- - `out` - the output path, can be a directory or a file path, but please just use it as a filename
342
+ - `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/scss/*.scss"`). Globs must use `/` separators (even on Windows) and skip Sass partials (`_*.scss`). Each matched file is compiled separately
343
+ - `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — each input compiles to `<out>/<basename>.css`, so inputs sharing a basename (e.g. `a/main.scss` and `b/main.scss`) will overwrite each other
313
344
  - `options` - the options for the bundler.
314
345
 
315
346
  **Options:**
@@ -465,37 +496,34 @@ You can also pass options to plugins using the tuple form:
465
496
 
466
497
  #### Tailwind CSS Example
467
498
 
468
- The `example-tailwind/` directory demonstrates using Tailwind CSS v4 with Poops. To run it:
499
+ Install the deps, then use a config like this:
469
500
 
470
501
  ```bash
471
502
  npm i -D postcss @tailwindcss/postcss tailwindcss
472
- node poops.js -c example-tailwind/poops.json
473
503
  ```
474
504
 
475
- The example config (`example-tailwind/poops.json`):
476
-
477
505
  ```json
478
506
  {
479
507
  "postcss": {
480
- "in": "example-tailwind/src/css/main.css",
481
- "out": "example-tailwind/dist/css/main.css",
508
+ "in": "src/css/main.css",
509
+ "out": "dist/css/main.css",
482
510
  "options": {
483
511
  "plugins": ["@tailwindcss/postcss"],
484
512
  "minify": true
485
513
  }
486
514
  },
487
515
  "markup": {
488
- "in": "example-tailwind/src/markup",
489
- "out": "example-tailwind/dist",
516
+ "in": "src/markup",
517
+ "out": "dist",
490
518
  "site": {
491
519
  "title": "Poops + Tailwind",
492
520
  "description": "A Tailwind CSS example for Poops"
493
521
  },
494
522
  "includePaths": ["_layouts", "_partials"]
495
523
  },
496
- "serve": { "port": 4041, "base": "/example-tailwind/dist" },
524
+ "serve": { "port": 4040, "base": "/dist" },
497
525
  "livereload": true,
498
- "watch": ["example-tailwind/src"]
526
+ "watch": ["src"]
499
527
  }
500
528
  ```
501
529
 
@@ -592,16 +620,26 @@ An engine class implements this contract (see [`lib/markup/engines/`](lib/markup
592
620
 
593
621
  ```js
594
622
  export default class MyEngine {
595
- constructor(templatesDir, includePaths, options) {} // options: { autoescape }
596
- get fileExtension() { return '.liquid' } // native template extension
597
- get indexableExtensions() { return new Set(['.html']) } // extensions eligible for search index/nav
598
- get markupExtensions() { return 'html|liquid|md' } // glob alternation of processed extensions
623
+ constructor(templatesDir, includePaths, options) {} // options: { autoescape }
624
+ get fileExtension() {
625
+ return ".liquid";
626
+ } // native template extension
627
+ get indexableExtensions() {
628
+ return new Set([".html"]);
629
+ } // extensions eligible for search index/nav
630
+ get markupExtensions() {
631
+ return "html|liquid|md";
632
+ } // glob alternation of processed extensions
599
633
  registerFilters({ timeDateFormat, markupOut }) {}
600
634
  registerTags(getOutputDir) {}
601
635
  setGlobal(key, value) {}
602
636
  removeGlobal(key) {}
603
- async render(templatePath, context) { return 'html' } // templatePath is an absolute file path
604
- async renderString(source, context) { return 'html' }
637
+ async render(templatePath, context) {
638
+ return "html";
639
+ } // templatePath is an absolute file path
640
+ async renderString(source, context) {
641
+ return "html";
642
+ }
605
643
  }
606
644
  ```
607
645
 
@@ -610,12 +648,12 @@ Optionally, an engine may implement `replaceOutExtensions(outputPath)` to contro
610
648
  The easiest starting point is extending a built-in engine — deep imports are intentionally supported for this:
611
649
 
612
650
  ```js
613
- import LiquidEngine from 'poops/lib/markup/engines/liquid.js'
651
+ import LiquidEngine from "poops/lib/markup/engines/liquid.js";
614
652
 
615
653
  export default class MyEngine extends LiquidEngine {
616
654
  registerFilters(opts) {
617
- super.registerFilters(opts)
618
- this.engine.registerFilter('shout', (str) => String(str).toUpperCase())
655
+ super.registerFilters(opts);
656
+ this.engine.registerFilter("shout", (str) => String(str).toUpperCase());
619
657
  }
620
658
  }
621
659
  ```
@@ -858,7 +896,7 @@ All filters are available in both engines. The only syntax difference is how arg
858
896
 
859
897
  - `jsonify` — serializes a value to JSON. Usage: `{{ myObject | jsonify }}`
860
898
 
861
- - `markdown` — renders a markdown string to HTML. Usage: `{{ "**bold**" | markdown }}`
899
+ - `markdown` — renders a markdown string to HTML with GitHub-style emoji shortcodes (e.g. `:rocket:` → 🚀). Usage: `{{ "**bold** :rocket:" | markdown }}`
862
900
 
863
901
  - `date` — formats a date string. Uses [dayjs](https://day.js.org/) format tokens. A default format can be set via the `timeDateFormat` config option.
864
902
  - Nunjucks: `{{ "2024-01-15" | date("MMMM D, YYYY") }}`
@@ -1088,12 +1126,23 @@ The string shorthand sets the output filename. For docs sites, use the object fo
1088
1126
 
1089
1127
  ```json
1090
1128
  [
1091
- { "title": "Guide", "url": "guide", "order": 1, "children": [
1092
- { "title": "Getting Started", "url": "guide/getting-started", "order": 1 },
1093
- { "title": "Advanced", "url": "guide/advanced", "children": [
1094
- { "title": "Config", "url": "guide/advanced/config" }
1095
- ]}
1096
- ]}
1129
+ {
1130
+ "title": "Guide",
1131
+ "url": "guide",
1132
+ "order": 1,
1133
+ "children": [
1134
+ {
1135
+ "title": "Getting Started",
1136
+ "url": "guide/getting-started",
1137
+ "order": 1
1138
+ },
1139
+ {
1140
+ "title": "Advanced",
1141
+ "url": "guide/advanced",
1142
+ "children": [{ "title": "Config", "url": "guide/advanced/config" }]
1143
+ }
1144
+ ]
1145
+ }
1097
1146
  ]
1098
1147
  ```
1099
1148
 
@@ -1,4 +1,6 @@
1
1
  import { Marked } from 'marked'
2
+ import { markedGithubEmoji } from 'marked-github-emoji'
3
+ import { markedGithubAlerts } from 'marked-github-alerts'
2
4
  import { slugify } from 'book-of-spells'
3
5
  import { highlightRenderer, highlightCode } from './highlight.js'
4
6
  import { decodeTemplateEntities } from './helpers.js'
@@ -50,6 +52,12 @@ export const markdownRenderer = {
50
52
 
51
53
  // One shared instance so both engines render markdown identically.
52
54
  export const marked = new Marked({ renderer: markdownRenderer })
55
+ marked.use(markedGithubEmoji())
56
+ marked.use(markedGithubAlerts({
57
+ alerts: {
58
+ info: { title: 'Info', icon: 'info' }
59
+ }
60
+ }))
53
61
 
54
62
  // Renders a markdown page source for the template engines. Entity decoding
55
63
  // (which un-escapes inside {{ }} / {% %} so template args parse) must skip
package/lib/styles.js CHANGED
@@ -1,7 +1,9 @@
1
1
  import fs from 'node:fs'
2
+ import { globSync, hasMagic } from 'glob'
2
3
  import {
3
4
  pathExists,
4
5
  mkPath,
6
+ pathForFile,
5
7
  buildStyleOutputFilePath,
6
8
  fillBannerTemplate,
7
9
  buildTime,
@@ -25,12 +27,35 @@ export default class Styles {
25
27
  this.config.styles = Array.isArray(this.config.styles) ? this.config.styles : [this.config.styles]
26
28
  for (const styleEntry of this.config.styles) {
27
29
  if (!styleEntry.in || !styleEntry.out) continue
28
- if (!pathExists(styleEntry.in)) {
29
- log({ tag: 'style', error: true, text: 'Entry does not exist:', link: styleEntry.in })
30
- continue
30
+ // `in` may be an array of entry points and/or globs — same resolution as scripts
31
+ const configured = Array.isArray(styleEntry.in) ? styleEntry.in : [styleEntry.in]
32
+ const entryPoints = []
33
+ let missing = false
34
+ for (const entry of configured) {
35
+ if (hasMagic(entry)) {
36
+ // Globs must use `/` even on Windows; sort for deterministic build order.
37
+ // Skip sass partials (_*.scss) — they are imports, not entry points.
38
+ const matches = globSync(entry, { posix: true }).filter(match => !path.basename(match).startsWith('_')).sort()
39
+ if (!matches.length) {
40
+ log({ tag: 'style', error: true, text: 'Entry does not exist:', link: entry })
41
+ missing = true
42
+ }
43
+ entryPoints.push(...matches)
44
+ } else if (!pathExists(entry)) {
45
+ log({ tag: 'style', error: true, text: 'Entry does not exist:', link: entry })
46
+ missing = true
47
+ } else {
48
+ entryPoints.push(entry)
49
+ }
50
+ }
51
+ if (missing) continue
52
+ if (entryPoints.length > 1 && pathForFile(styleEntry.out)) {
53
+ log({ tag: 'error', text: 'Cannot output multiple style files to a single file. Please specify an output directory path instead.' })
54
+ process.exit(1)
55
+ }
56
+ for (const entryPoint of entryPoints) {
57
+ await this.compileEntry(entryPoint, styleEntry.out, styleEntry.options)
31
58
  }
32
- mkPath(styleEntry.out)
33
- await this.compileEntry(styleEntry.in, styleEntry.out, styleEntry.options)
34
59
  }
35
60
  }
36
61
 
@@ -58,6 +83,7 @@ export default class Styles {
58
83
  }
59
84
 
60
85
  outfilePath = buildStyleOutputFilePath(infilePath, outfilePath)
86
+ mkPath(outfilePath) // resolved file path — mkPath on a dir out is a no-op
61
87
 
62
88
  const stylesStart = performance.now()
63
89
  let compiledSass
@@ -181,3 +181,33 @@ export function doesFileBelongToPath(filePath, configPaths) {
181
181
  }
182
182
  return false
183
183
  }
184
+
185
+ // Derive the watch list from every task's `in` path when watch is `true`.
186
+ // File entries (scripts/styles/reactor) resolve to their parent dir so sibling
187
+ // imports still retrigger a rebuild; dir entries (markup/copy/images) are
188
+ // watched as-is. Imports that reach outside a task's own dir aren't covered —
189
+ // use an explicit `watch` array for that. includePaths is skipped on purpose:
190
+ // it defaults to node_modules, which must never be watched.
191
+ export function deriveWatchDirs(config) {
192
+ const paths = []
193
+ const collect = (task) => {
194
+ if (!task) return
195
+ for (const entry of [].concat(task)) {
196
+ if (!entry || typeof entry !== 'object') continue
197
+ if (entry.in) paths.push(...(Array.isArray(entry.in) ? entry.in : [entry.in]))
198
+ if (entry.component) paths.push(entry.component) // reactor source component
199
+ const tokenPaths = entry.options && entry.options.tokenPaths
200
+ if (tokenPaths) paths.push(...[].concat(tokenPaths))
201
+ }
202
+ }
203
+ collect(config.scripts)
204
+ collect(config.styles)
205
+ collect(config.reactor)
206
+ collect(config.markup)
207
+ collect(config.copy)
208
+ collect(config.images)
209
+ // ponytail: extname()-based dir/file split misreads dot-named dirs (rare);
210
+ // the escape hatch is an explicit watch array.
211
+ return [...new Set(paths.map((p) => (path.extname(p) ? path.dirname(p) : p)))]
212
+ .filter((p) => p && p !== '.')
213
+ }
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.3.0",
4
+ "version": "1.4.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
@@ -62,6 +62,8 @@
62
62
  "liquidjs": "^10.24.0",
63
63
  "livereload": "^0.9.3",
64
64
  "marked": "^9.0.3",
65
+ "marked-github-alerts": "^1.0.1",
66
+ "marked-github-emoji": "^1.0.2",
65
67
  "nunjucks": "^3.2.4",
66
68
  "portscanner": "^2.2.0",
67
69
  "printstyle": "^1.0.0",
package/poops.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import chokidar from 'chokidar'
4
4
  import connect from 'connect'
5
5
  import Copy from './lib/copy.js'
6
- import { pathExists, doesFileBelongToPath, pathContainsPathSegment } from './lib/utils/helpers.js'
6
+ import { pathExists, doesFileBelongToPath, pathContainsPathSegment, deriveWatchDirs } from './lib/utils/helpers.js'
7
7
  import http from 'node:http'
8
8
  import os from 'node:os'
9
9
  import fs from 'node:fs'
@@ -249,7 +249,9 @@ if (!pathExists(configPath)) {
249
249
  // Load poops.json
250
250
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
251
251
 
252
- if (config.watch) {
252
+ if (config.watch === true) {
253
+ config.watch = deriveWatchDirs(config)
254
+ } else if (config.watch) {
253
255
  config.watch = Array.isArray(config.watch) ? config.watch : [config.watch]
254
256
  }
255
257