poops 1.7.0 → 1.8.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
@@ -1,4 +1,4 @@
1
- # 💩 Poops [![npm version](https://img.shields.io/npm/v/poops)](https://www.npmjs.com/package/poops)
1
+ # 💩 Poops [![npm version](https://img.shields.io/npm/v/poops)](https://www.npmjs.com/package/poops) [![build status](https://github.com/stamat/poops/actions/workflows/ci.yml/badge.svg)](https://github.com/stamat/poops/actions/workflows/ci.yml) [![license](https://img.shields.io/github/license/stamat/poops.svg)](https://github.com/stamat/poops/blob/main/LICENSE)
2
2
 
3
3
  Straightforward, no-bullshit bundler for the web.
4
4
 
@@ -551,6 +551,17 @@ Then use Tailwind utility classes directly in your markup templates. Tailwind v4
551
551
 
552
552
  **💡 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 }}`.
553
553
 
554
+ **"Edit this page on GitHub" links.** Every page exposes `page.filePath` — its source file path relative to your project root, with posix separators (e.g. `src/markup/docs/index.md`). That is exactly the path GitHub's editor expects, so an edit link is one line in your layout:
555
+
556
+ ```nunjucks
557
+ {% set repoUrl = site.repo or package.homepage %}
558
+ {% if page.filePath and repoUrl %}
559
+ <a href="{{ repoUrl }}/edit/{{ site.branch or 'main' }}/{{ page.filePath }}">✏️ Edit this page on GitHub</a>
560
+ {% endif %}
561
+ ```
562
+
563
+ Put `repo` and `branch` in your `site` data (they fall back to `package.homepage` and `main`). Don't rebuild the path from `page.url` — that is the output URL (`.html`, and `index.md` collapses to a directory), so it can't be reversed to the `.md` source.
564
+
554
565
  Here is a sample markup configuration using the default Nunjucks engine:
555
566
 
556
567
  ```json
@@ -784,9 +795,9 @@ title: Changelog
784
795
  collection: true
785
796
  paginate: 10
786
797
  taxonomies:
787
- - name: tags # front-matter field to group on
788
- path: tag # URL segment (defaults to name); "tag" for a singular URL
789
- paginate: 5 # per-term page size (defaults to the collection's paginate)
798
+ - name: tags # front-matter field to group on
799
+ path: tag # URL segment (defaults to name); "tag" for a singular URL
800
+ paginate: 5 # per-term page size (defaults to the collection's paginate)
790
801
  ---
791
802
  ```
792
803
 
@@ -871,9 +882,15 @@ Output:
871
882
  ```
872
883
 
873
884
  ```html
874
- <img src="static/photo-thumb-480w.webp"
875
- srcset="static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w"
876
- width="480" height="480" sizes="240px" alt="" loading="lazy">
885
+ <img
886
+ src="static/photo-thumb-480w.webp"
887
+ srcset="static/photo-thumb-480w.webp 480w, static/photo-thumb.webp 960w"
888
+ width="480"
889
+ height="480"
890
+ sizes="240px"
891
+ alt=""
892
+ loading="lazy"
893
+ />
877
894
  ```
878
895
 
879
896
  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.
@@ -1041,7 +1058,7 @@ All filters are available in both engines. The only syntax difference is how arg
1041
1058
 
1042
1059
  poops auto-picks `BlogPosting` (page has a `date`) or `WebPage`. Override `@type` with the `jsonld` object for any schema.org type — common ones search/generative engines act on: `Article`, `NewsArticle`, `HowTo`, `FAQPage`, `QAPage`, `Product`, `Recipe`, `Event`, `Course`, `VideoObject`, `SoftwareApplication`, `Organization`, `Person`, `BreadcrumbList`, `WebSite`. Full list at [schema.org/docs/full](https://schema.org/docs/full.html); validate with the [Rich Results Test](https://search.google.com/test/rich-results). A per-`@type` table with the notable fields is in the [Templating docs](example/src/markup/docs/quick-start/templating-html.md).
1043
1060
 
1044
- - `breadcrumb` — generates a visible breadcrumb `<nav class="breadcrumb"><ol>…</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` → *Static Site*), then the current page as `aria-current` text. Pass `relativePathPrefix` so links resolve against the current output location (localhost in dev, your deployed subpath in prod) — not the absolute domain.
1061
+ - `breadcrumb` — generates a visible breadcrumb `<nav class="breadcrumb"><ol>…</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` → _Static Site_), then the current page as `aria-current` text. Pass `relativePathPrefix` so links resolve against the current output location (localhost in dev, your deployed subpath in prod) — not the absolute domain.
1045
1062
  - Nunjucks: `{{ page | breadcrumb(site, relativePathPrefix) }}`
1046
1063
  - Liquid: `{{ page | breadcrumb: site, relativePathPrefix }}`
1047
1064
 
@@ -1076,7 +1093,7 @@ All filters are available in both engines. The only syntax difference is how arg
1076
1093
 
1077
1094
  Returns: `static/photo-320w.webp 320w, static/photo-640w.webp 640w, static/photo-960w.webp 960w`
1078
1095
 
1079
- 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`.
1096
+ 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`.
1080
1097
 
1081
1098
  - `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`).
1082
1099
 
@@ -1241,7 +1258,8 @@ Pages with `published: false` in their front matter are excluded from all output
1241
1258
  A page's front matter `robots: noindex` (or `none`) drops it from the **sitemap and llms.txt** — for drafts, thin or utility pages (a 404, say) you don't want crawled or fed to LLMs. It stays in the search index (that's your own on-site search). Emit the matching crawler directive in your layout `<head>` so the page itself carries it:
1242
1259
 
1243
1260
  ```html
1244
- {% if page.robots %}<meta name="robots" content="{{ page.robots }}">{% endif %}
1261
+ {% if page.robots %}<meta name="robots" content="{{ page.robots }}" />{% endif
1262
+ %}
1245
1263
  ```
1246
1264
 
1247
1265
  **Navigation tree** builds your page hierarchy as sidebar-ready data, exposed two ways: as the `nav` template global (loaded automatically, always reflecting the current build) and as a nested JSON file for client-side rendering. Subpages nest automatically from URL structure: `guide/index.md` becomes a parent node and `guide/getting-started.md`, `guide/advanced/config.md` become its (and its subsections') children. Add `nav` to your markup config:
@@ -1385,7 +1403,11 @@ Shorthand forms: `"feed": true` (or a filename string) emits an RSS feed for eve
1385
1403
  Point browsers and readers at it from your layout `<head>`:
1386
1404
 
1387
1405
  ```html
1388
- <link rel="alternate" type="application/rss+xml" href="{{ site.url }}/blog/feed.rss">
1406
+ <link
1407
+ rel="alternate"
1408
+ type="application/rss+xml"
1409
+ href="{{ site.url }}/blog/feed.rss"
1410
+ />
1389
1411
  ```
1390
1412
 
1391
1413
  ### Images (optional)
package/lib/copy.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  mkDir,
6
6
  copyDirectory,
7
7
  buildTime,
8
- toPosix
8
+ replacePathSegment
9
9
  } from './utils/helpers.js'
10
10
  import fs from 'node:fs'
11
11
  import path from 'node:path'
@@ -92,8 +92,10 @@ export default class Copy {
92
92
  copyPaths.out = path.join(copyPaths.out, inBaseName)
93
93
  }
94
94
 
95
- // Watcher paths arrive with native separators, config `in` uses `/`
96
- file = toPosix(file).replace(copyPaths.in, copyPaths.out)
95
+ // Watcher paths arrive with native separators, config `in` uses `/`.
96
+ // Segment-aware swap so an `in` that also appears as a substring inside an
97
+ // earlier path segment doesn't rewrite the wrong part (and delete a wrong file).
98
+ file = replacePathSegment(file, copyPaths.in, copyPaths.out)
97
99
 
98
100
  const outputFilePath = path.resolve(process.cwd(), file)
99
101
 
@@ -252,6 +252,30 @@ function registerGoogleFontsTag(engine) {
252
252
  })
253
253
  }
254
254
 
255
+ // Splits an {% image %} arg string on top-level commas only, so a quoted value
256
+ // with commas (e.g. sizes: "(max-width: 600px) 100vw, 50vw") stays one part.
257
+ function splitArgsOutsideQuotes(str) {
258
+ const parts = []
259
+ let cur = ''
260
+ let quote = null
261
+ for (const ch of str) {
262
+ if (quote) {
263
+ if (ch === quote) quote = null
264
+ cur += ch
265
+ } else if (ch === '"' || ch === "'") {
266
+ quote = ch
267
+ cur += ch
268
+ } else if (ch === ',') {
269
+ parts.push(cur.trim())
270
+ cur = ''
271
+ } else {
272
+ cur += ch
273
+ }
274
+ }
275
+ parts.push(cur.trim())
276
+ return parts
277
+ }
278
+
255
279
  function registerImageTag(engine, getOutputDir) {
256
280
  engine.registerTag('image', {
257
281
  parse(tagToken) {
@@ -259,7 +283,7 @@ function registerImageTag(engine, getOutputDir) {
259
283
  },
260
284
  * render(ctx) {
261
285
  const argsStr = this.args.trim()
262
- const parts = argsStr.split(',').map(s => s.trim())
286
+ const parts = splitArgsOutsideQuotes(argsStr)
263
287
 
264
288
  let imagePath = parts[0]
265
289
  if (imagePath.startsWith('"') || imagePath.startsWith("'")) {
@@ -15,8 +15,9 @@ class RelativeLoader extends nunjucks.Loader {
15
15
  constructor(templatesDir, includePaths, onTouch) {
16
16
  super()
17
17
  this.templatesDir = templatesDir
18
- this.includePaths = includePaths || []
19
- this.includePaths.push('_*')
18
+ // Copy, don't alias+mutate: the caller reuses this array to build the
19
+ // markup glob exclusions, so pushing '_*' here would leak into it.
20
+ this.includePaths = [...(includePaths || []), '_*']
20
21
  // Reports every compiled-template cache touch (hit or write) with the
21
22
  // backing file path — the Environment reads loader.cache directly on
22
23
  // hits, so getSource alone never sees reuse of already-compiled templates.
@@ -52,7 +52,9 @@ export function extractKeywords(htmlContent, options = {}) {
52
52
  const text = htmlContent.replace(/<[^>]*>/g, ' ')
53
53
  const words = text
54
54
  .toLowerCase()
55
- .replace(/[^a-z0-9\-\s]/g, ' ')
55
+ // Keep any-script letters/numbers (\p{L}\p{N}) so non-English sites get
56
+ // keywords too — an ASCII-only class stripped every accented/CJK char.
57
+ .replace(/[^\p{L}\p{N}\-\s]/gu, ' ')
56
58
  .split(/\s+/)
57
59
  .filter(w => w.length >= minWordLength && !stopWords.has(w) && !/^\d+$/.test(w))
58
60
 
@@ -158,6 +160,7 @@ export function generateSearchIndex(pageEntries, outputDir, config) {
158
160
  // resolve, not join: outputDir may be absolute (join would mangle it,
159
161
  // e.g. cross-drive temp dirs on Windows)
160
162
  const outputPath = path.resolve(process.cwd(), outputDir, config.output)
163
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
161
164
  fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
162
165
  log({ tag: 'indexer', text: 'Generated search index:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
163
166
  }
@@ -188,6 +191,7 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
188
191
  // resolve, not join: outputDir may be absolute (join would mangle it,
189
192
  // e.g. cross-drive temp dirs on Windows)
190
193
  const outputPath = path.resolve(process.cwd(), outputDir, config.output)
194
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
191
195
  fs.writeFileSync(outputPath, xml)
192
196
  log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
193
197
  }
@@ -369,6 +373,7 @@ export function generateRobotsTxt(outputDir, siteUrl, config, sitemapOutput) {
369
373
  if (sitemapUrl) lines.push('', `Sitemap: ${sitemapUrl}`)
370
374
 
371
375
  const outputPath = path.resolve(process.cwd(), outputDir, config.output)
376
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
372
377
  fs.writeFileSync(outputPath, lines.join('\n') + '\n')
373
378
  log({ tag: 'indexer', text: 'Generated robots.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
374
379
  }
@@ -512,6 +517,7 @@ export function generateNav(pageEntries, outputDir, config) {
512
517
  // resolve, not join: outputDir may be absolute (join would mangle it,
513
518
  // e.g. cross-drive temp dirs on Windows)
514
519
  const outputPath = path.resolve(process.cwd(), outputDir, config.output)
520
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true })
515
521
  fs.writeFileSync(outputPath, JSON.stringify(tree, null, 2))
516
522
  log({ tag: 'indexer', text: 'Generated nav:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
517
523
  }
package/lib/markups.js CHANGED
@@ -144,13 +144,19 @@ export default class Markups {
144
144
 
145
145
  const loadedKeys = new Set()
146
146
  for (const dataFile of resolved) {
147
+ const globalKeyName = path.basename(dataFile, path.extname(dataFile)).replace(/[.\-\s]/g, '_')
148
+ // Two data files with the same basename collide on one global (last wins) —
149
+ // warn, since it's silent data loss otherwise.
150
+ if (loadedKeys.has(globalKeyName)) {
151
+ log({ tag: 'markup', warn: true, text: `Data file overrides global "${globalKeyName}" (duplicate basename):`, link: dataFile })
152
+ }
147
153
  try {
148
154
  const data = readDataFile(path.resolve(process.cwd(), dataDir, dataFile))
149
- const globalKeyName = path.basename(dataFile, path.extname(dataFile)).replace(/[.\-\s]/g, '_')
150
155
  this.engine.setGlobal(globalKeyName, data)
151
156
  loadedKeys.add(globalKeyName)
152
157
  } catch (err) {
153
- log({ tag: 'error', text: 'Data file not found:', link: dataFile })
158
+ // Covers both a missing file and a parse error — surface which.
159
+ log({ tag: 'error', text: `Failed loading data file (${err.message}):`, link: dataFile })
154
160
  continue
155
161
  }
156
162
  }
@@ -347,7 +353,12 @@ export default class Markups {
347
353
  relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
348
354
  // output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
349
355
  // otherwise `item.url == page.url` sidebar/active checks never fire
350
- _url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
356
+ _url: getPageUrlRelativeToOutput(markupOut, this.markupOut),
357
+ // source path relative to cwd/repo-root (posix), matching collection items'
358
+ // filePath, so templates can build "edit on GitHub" links as
359
+ // repoUrl + /edit/<branch>/ + filePath. .html output can't be reversed to
360
+ // the .md source reliably; cwd is the repo root
361
+ _page: { filePath: toPosix(path.relative(process.cwd(), path.resolve(file))) }
351
362
  }
352
363
 
353
364
  const shouldIndex = pageEntries && this.engine.indexableExtensions.has(path.extname(file))
package/lib/server.js ADDED
@@ -0,0 +1,146 @@
1
+ /* Minimal static file server replacing connect + serve-static.
2
+ Supports single-range requests (in-browser video seeking, Safari playback).
3
+ ponytail: no ETag/Cache-Control — dev server wants fresh responses, not 304s. */
4
+ import fs from 'node:fs'
5
+ import path from 'node:path'
6
+ import { pathExists } from './utils/helpers.js'
7
+
8
+ const MIME_TYPES = {
9
+ '.html': 'text/html; charset=utf-8',
10
+ '.css': 'text/css; charset=utf-8',
11
+ '.js': 'text/javascript; charset=utf-8',
12
+ '.mjs': 'text/javascript; charset=utf-8',
13
+ '.json': 'application/json; charset=utf-8',
14
+ '.map': 'application/json; charset=utf-8',
15
+ '.xml': 'application/xml; charset=utf-8',
16
+ '.txt': 'text/plain; charset=utf-8',
17
+ '.md': 'text/markdown; charset=utf-8',
18
+ '.svg': 'image/svg+xml',
19
+ '.png': 'image/png',
20
+ '.jpg': 'image/jpeg',
21
+ '.jpeg': 'image/jpeg',
22
+ '.gif': 'image/gif',
23
+ '.webp': 'image/webp',
24
+ '.avif': 'image/avif',
25
+ '.ico': 'image/x-icon',
26
+ '.woff': 'font/woff',
27
+ '.woff2': 'font/woff2',
28
+ '.ttf': 'font/ttf',
29
+ '.otf': 'font/otf',
30
+ '.mp4': 'video/mp4',
31
+ '.webm': 'video/webm',
32
+ '.mp3': 'audio/mpeg',
33
+ '.wav': 'audio/wav',
34
+ '.ogg': 'audio/ogg',
35
+ '.pdf': 'application/pdf',
36
+ '.wasm': 'application/wasm',
37
+ '.webmanifest': 'application/manifest+json; charset=utf-8'
38
+ }
39
+
40
+ // Parse a single-range `Range: bytes=start-end` header against a file size.
41
+ // Returns { start, end } (inclusive), null when absent/ignorable (multipart,
42
+ // malformed — RFC says serve the full file then), or 'unsatisfiable' for a
43
+ // well-formed range outside the file (→ 416).
44
+ export function parseRange(header, size) {
45
+ if (!header) return null
46
+ const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim())
47
+ if (!match || (match[1] === '' && match[2] === '')) return null
48
+
49
+ let start, end
50
+ if (match[1] === '') {
51
+ // Suffix range: last N bytes
52
+ const suffix = parseInt(match[2])
53
+ if (suffix === 0) return 'unsatisfiable'
54
+ start = Math.max(size - suffix, 0)
55
+ end = size - 1
56
+ } else {
57
+ start = parseInt(match[1])
58
+ end = match[2] === '' ? size - 1 : Math.min(parseInt(match[2]), size - 1)
59
+ }
60
+
61
+ if (start >= size || start > end) return 'unsatisfiable'
62
+ return { start, end }
63
+ }
64
+
65
+ export function createStaticHandler(base) {
66
+ const notFoundPage = path.join(base, '404.html')
67
+
68
+ const notFound = (res) => {
69
+ res.statusCode = 404
70
+ if (pathExists(notFoundPage)) {
71
+ res.setHeader('Content-Type', 'text/html; charset=utf-8')
72
+ fs.createReadStream(notFoundPage).on('error', () => res.destroy()).pipe(res)
73
+ } else {
74
+ res.end('Not Found')
75
+ }
76
+ }
77
+
78
+ return (req, res) => {
79
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
80
+ res.statusCode = 405
81
+ res.setHeader('Allow', 'GET, HEAD')
82
+ return res.end('Method Not Allowed')
83
+ }
84
+
85
+ let url, pathname
86
+ try {
87
+ url = new URL(req.url, 'http://localhost')
88
+ pathname = decodeURIComponent(url.pathname)
89
+ } catch {
90
+ res.statusCode = 400
91
+ return res.end('Bad Request')
92
+ }
93
+
94
+ // Contain resolution inside `base` — rejects `..` traversal and null bytes
95
+ if (pathname.includes('\0')) return notFound(res)
96
+ let filePath = path.normalize(path.join(base, pathname))
97
+ if (filePath !== base && !filePath.startsWith(base + path.sep)) return notFound(res)
98
+
99
+ let stat
100
+ try {
101
+ stat = fs.statSync(filePath)
102
+ } catch {
103
+ return notFound(res)
104
+ }
105
+
106
+ if (stat.isDirectory()) {
107
+ // Redirect /dir → /dir/ so the page's relative URLs resolve correctly
108
+ if (!pathname.endsWith('/')) {
109
+ res.statusCode = 301
110
+ res.setHeader('Location', encodeURI(pathname) + '/' + url.search)
111
+ return res.end()
112
+ }
113
+ filePath = path.join(filePath, 'index.html')
114
+ try {
115
+ stat = fs.statSync(filePath)
116
+ } catch {
117
+ return notFound(res)
118
+ }
119
+ }
120
+
121
+ res.setHeader('Content-Type', MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream')
122
+ res.setHeader('Accept-Ranges', 'bytes')
123
+
124
+ const range = parseRange(req.headers.range, stat.size)
125
+ if (range === 'unsatisfiable') {
126
+ res.statusCode = 416
127
+ res.setHeader('Content-Range', `bytes */${stat.size}`)
128
+ return res.end()
129
+ }
130
+
131
+ const streamOpts = {}
132
+ if (range) {
133
+ res.statusCode = 206
134
+ res.setHeader('Content-Range', `bytes ${range.start}-${range.end}/${stat.size}`)
135
+ res.setHeader('Content-Length', range.end - range.start + 1)
136
+ streamOpts.start = range.start
137
+ streamOpts.end = range.end
138
+ } else {
139
+ res.statusCode = 200
140
+ res.setHeader('Content-Length', stat.size)
141
+ }
142
+
143
+ if (req.method === 'HEAD') return res.end()
144
+ fs.createReadStream(filePath, streamOpts).on('error', () => res.destroy()).pipe(res)
145
+ }
146
+ }
@@ -165,6 +165,22 @@ export function stripDirNavSegments(filePath) {
165
165
  return toPosix(path.normalize(filePath)).replace(/(\.\.\/|\.\/|\/\.\.|\.\.\\|\.\\|\\\.\.)/g, '')
166
166
  }
167
167
 
168
+ // Index in filePath's segment list where `segParts` first appears as a
169
+ // contiguous run, or -1. Segment-boundary matching: `src` matches `src/app.js`
170
+ // and `x/src/y` but never `mysrc/app.js` or `dist/src-maps/x` — a plain
171
+ // substring test would false-positive on all three.
172
+ function findSegmentRun(fileParts, segParts) {
173
+ if (segParts.length === 0) return -1
174
+ for (let i = 0; i + segParts.length <= fileParts.length; i++) {
175
+ let match = true
176
+ for (let j = 0; j < segParts.length; j++) {
177
+ if (fileParts[i + j] !== segParts[j]) { match = false; break }
178
+ }
179
+ if (match) return i
180
+ }
181
+ return -1
182
+ }
183
+
168
184
  export function pathContainsPathSegment(filePath, segment) {
169
185
  // Watcher paths arrive with native separators, config segments with `/`
170
186
  filePath = toPosix(filePath)
@@ -173,9 +189,23 @@ export function pathContainsPathSegment(filePath, segment) {
173
189
  segment = convertGlobToRegex(segment)
174
190
  if (!segment) return false
175
191
  return segment.test(filePath)
176
- } else {
177
- return filePath.includes(segment)
178
192
  }
193
+ const segParts = segment.split('/').filter(Boolean)
194
+ return findSegmentRun(filePath.split('/').filter(Boolean), segParts) !== -1
195
+ }
196
+
197
+ // Swaps the first segment-boundary occurrence of `from` in `filePath` for
198
+ // `to`, both posix. Segment-aware so `src`→`dist` on `mysrc/src/x` rewrites the
199
+ // real `src` segment, not the substring inside `mysrc` (what String.replace would hit).
200
+ export function replacePathSegment(filePath, from, to) {
201
+ const fileParts = toPosix(filePath).split('/')
202
+ const nonEmpty = fileParts.filter(Boolean)
203
+ const offset = fileParts.length - nonEmpty.length // leading '' from an absolute path
204
+ const fromParts = toPosix(from).split('/').filter(Boolean)
205
+ const at = findSegmentRun(nonEmpty, fromParts)
206
+ if (at === -1) return toPosix(filePath)
207
+ const i = at + offset
208
+ return [...fileParts.slice(0, i), to, ...fileParts.slice(i + fromParts.length)].join('/')
179
209
  }
180
210
 
181
211
  export function doesFileBelongToPath(filePath, configPaths) {
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.7.0",
4
+ "version": "1.8.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
@@ -48,13 +48,15 @@
48
48
  "scripts": {
49
49
  "build": "node ./poops.js -b",
50
50
  "lint": "eslint .",
51
- "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
51
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
52
+ },
53
+ "engines": {
54
+ "node": ">=20"
52
55
  },
53
56
  "dependencies": {
54
57
  "argoyle": "^1.0.0",
55
58
  "book-of-spells": "^1.3.0",
56
59
  "chokidar": "^3.5.3",
57
- "connect": "^3.7.0",
58
60
  "dayjs": "^1.11.19",
59
61
  "esbuild": "^0.25.0",
60
62
  "glob": "^13.0.6",
@@ -66,12 +68,10 @@
66
68
  "marked-github-emoji": "^1.0.2",
67
69
  "marked-github-footnote": "^1.0.0",
68
70
  "nunjucks": "^3.2.4",
69
- "portscanner": "^2.2.0",
70
71
  "printstyle": "^1.0.0",
71
72
  "sass": "^1.63.4",
72
73
  "sass-path-resolver": "^1.0.2",
73
74
  "sass-token-importer": "^1.0.0",
74
- "serve-static": "^1.15.0",
75
75
  "yaml": "^2.3.1"
76
76
  },
77
77
  "devDependencies": {
package/poops.js CHANGED
@@ -1,25 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import chokidar from 'chokidar'
4
- import connect from 'connect'
5
4
  import Copy from './lib/copy.js'
6
5
  import runExec, { validateExec } from './lib/exec.js'
7
- import { pathExists, doesFileBelongToPath, pathContainsPathSegment, deriveWatchDirs } from './lib/utils/helpers.js'
6
+ import { pathExists, doesFileBelongToPath, pathContainsPathSegment, deriveWatchDirs, toPosix } from './lib/utils/helpers.js'
8
7
  import http from 'node:http'
8
+ import net from 'node:net'
9
9
  import os from 'node:os'
10
10
  import fs from 'node:fs'
11
11
  import livereload from 'livereload'
12
12
  import Markups from './lib/markups.js'
13
13
  import Images from './lib/images.js'
14
14
  import path from 'node:path'
15
- import serveStatic from 'serve-static'
16
15
  import Reactor from './lib/reactor.js'
16
+ import { createStaticHandler } from './lib/server.js'
17
17
  import Scripts from './lib/scripts.js'
18
18
  import log, { styledLog, hasLoggedErrors } from './lib/utils/log.js'
19
19
  import Styles from './lib/styles.js'
20
20
  import PostCSS from './lib/postcss.js'
21
21
  import Argoyle from 'argoyle'
22
- import portscanner from 'portscanner'
23
22
 
24
23
  const cwd = process.cwd() // Current Working Directory
25
24
  const pkg = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf-8'))
@@ -47,7 +46,7 @@ const overrideLivereloadPort = flags['livereload-port']
47
46
  const overrideBaseURL = flags['base-url']
48
47
 
49
48
  let configPath = path.join(cwd, defaultConfigPath)
50
- if (!pathExists(configPath)) configPath = path.join(cwd, '💩.json') // TODO: Ok dude, I know it's late, but you can do better than this.
49
+ if (!pathExists(configPath)) configPath = path.join(cwd, '💩.json') // the canonical alternative config name
51
50
 
52
51
  async function resolveLiveReloadPort(config) {
53
52
  if (!config.livereload) return null
@@ -91,10 +90,12 @@ function reload(file) {
91
90
  // The css output paths of the styles entries — what the styles chain reports
92
91
  // to reload() so style edits hot-swap. A directory `out` maps to the entry
93
92
  // point's basename, mirroring how the styles compiler names its output file.
93
+ // toPosix: the livereload client matches these against URL paths, so Windows
94
+ // backslashes would silently break the CSS hot-swap.
94
95
  function styleOutputs(config) {
95
96
  return [config.styles].flat()
96
97
  .filter((entry) => entry && entry.in && entry.out)
97
- .map((entry) => (path.extname(entry.out)
98
+ .map((entry) => toPosix(path.extname(entry.out)
98
99
  ? entry.out
99
100
  : path.join(entry.out, path.basename(entry.in).replace(/\.(sass|scss)$/i, '.css'))))
100
101
  }
@@ -126,6 +127,10 @@ function setupWatchers(config, modules) {
126
127
  // compiler, or watch loops forever. Zones are the dirs the compilers write
127
128
  // into; only their own extensions are skipped there, so e.g. a hand-edited
128
129
  // .liquid asset in the same dir still rebuilds markup.
130
+ // markup/images outs are deliberately NOT zoned: markup sources legitimately
131
+ // live beside markup output (Shopify theme dirs), so zoning them would stop
132
+ // hand-edits from rebuilding. Constraint: keep markup.out/images.out outside
133
+ // the watch list, or every compile retriggers itself.
129
134
  const outputZones = [config.scripts, config.styles].flat()
130
135
  .filter((entry) => entry && entry.out)
131
136
  .map((entry) => (path.extname(entry.out) ? path.dirname(entry.out) : entry.out))
@@ -230,6 +235,10 @@ function setupWatchers(config, modules) {
230
235
 
231
236
  chokidar.watch(config.watch, {
232
237
  ignoreInitial: true,
238
+ // Reactor's temp wrapper/bundle files are written next to the component
239
+ // source (a watched dir) — without this they'd retrigger the script/
240
+ // reactor rebuild that created them, looping the watcher.
241
+ ignored: /\.reactor-(tmp|bundle)-/,
233
242
  awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
234
243
  }).on('change', compileChanged)
235
244
  .on('add', (file) => {
@@ -302,6 +311,17 @@ if (!pathExists(configPath)) {
302
311
  // Load poops.json
303
312
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'))
304
313
 
314
+ // A typo'd top-level key ("stlyes") is otherwise silently ignored — warn, same
315
+ // idea as validateExec for exec stages. `ssg` is the reactor alias; `pkg` feeds
316
+ // banner templates; livereload_port/reactorData are set internally but tolerated
317
+ // here in case a config hardcodes them.
318
+ const KNOWN_CONFIG_KEYS = new Set(['watch', 'includePaths', 'scripts', 'styles', 'postcss', 'reactor', 'ssg', 'markup', 'copy', 'images', 'serve', 'livereload', 'exec', 'banner', 'pkg', 'livereload_port', 'reactorData'])
319
+ for (const key of Object.keys(config)) {
320
+ if (!KNOWN_CONFIG_KEYS.has(key)) {
321
+ log({ tag: 'info', warn: true, text: `Unknown config key "${key}" — ignored. Valid: ${[...KNOWN_CONFIG_KEYS].join(', ')}` })
322
+ }
323
+ }
324
+
305
325
  if (config.watch === true) {
306
326
  config.watch = deriveWatchDirs(config)
307
327
  } else if (config.watch) {
@@ -329,16 +349,23 @@ if (config.images && typeof config.images === 'object' && config.images.configDi
329
349
  config.images.configDir = path.dirname(configPath)
330
350
  }
331
351
 
352
+ // Bind probe on 0.0.0.0 — the same interface the servers listen on, so a
353
+ // "free" answer here can't be beaten to the port by a localhost-only check.
354
+ function isPortFree(port) {
355
+ return new Promise((resolve) => {
356
+ const probe = net.createServer()
357
+ probe.once('error', () => resolve(false))
358
+ probe.once('listening', () => probe.close(() => resolve(true)))
359
+ probe.listen(port, '0.0.0.0')
360
+ })
361
+ }
362
+
332
363
  async function getAvailablePort(port, max) {
333
- while (port < max) {
334
- const status = await portscanner.checkPortStatus(port, 'localhost')
335
- if (status === 'closed') {
336
- return port
337
- } else {
338
- port++
339
- }
364
+ for (; port <= max; port++) {
365
+ if (await isPortFree(port)) return port
340
366
  }
341
- return port
367
+ log({ tag: 'error', text: `No free port found in range ${max - 10}-${max}.` })
368
+ process.exit(1)
342
369
  }
343
370
 
344
371
  function getLocalIP() {
@@ -354,30 +381,16 @@ function getLocalIP() {
354
381
  async function startServer() {
355
382
  await resolveLiveReloadPort(config)
356
383
  await poops() // Initial compilation before starting the server
357
- const app = connect()
358
384
 
359
385
  const base = config.serve.base && pathExists(cwd, config.serve.base)
360
386
  ? path.join(cwd, config.serve.base)
361
387
  : cwd
362
388
 
363
- app.use(serveStatic(base))
364
-
365
- // Serve 404.html for unmatched routes
366
- const notFoundPage = path.join(base, '404.html')
367
- app.use((req, res) => {
368
- res.statusCode = 404
369
- if (pathExists(notFoundPage)) {
370
- fs.createReadStream(notFoundPage).pipe(res)
371
- } else {
372
- res.end('Not Found')
373
- }
374
- })
375
-
376
389
  let port = overridePort || config.serve.port || 4040
377
390
  if (!overridePort) port = await getAvailablePort(port, port + 10)
378
391
 
379
392
  // eslint-disable-next-line @stylistic/space-before-function-paren
380
- http.createServer(app).listen(parseInt(port), '0.0.0.0', async () => {
393
+ http.createServer(createStaticHandler(base)).listen(parseInt(port), '0.0.0.0', async () => {
381
394
  console.log()
382
395
  styledLog(`🏠 {dim}Local server:{/} {underline|http://localhost:${port}}`)
383
396
  styledLog(`🛜 {dim} Network :{/} {underline|http://${getLocalIP()}:${port}}`)
@@ -385,14 +398,22 @@ async function startServer() {
385
398
  })
386
399
  }
387
400
 
401
+ // A rejection here is a startup failure (port scan, initial compile) — exit
402
+ // loudly instead of dying as an unhandled rejection.
403
+ const die = (err) => {
404
+ console.error(err)
405
+ process.exit(1)
406
+ }
407
+
388
408
  // Start the webserver
389
409
  if (!build && config.serve) {
390
- startServer()
410
+ startServer().catch(die)
391
411
  } else if (!build && config.livereload) {
392
412
  // livereload without serve: still needs the livereload server
393
413
  resolveLiveReloadPort(config)
394
414
  .then(poops)
395
415
  .then(() => setupLiveReloadServer(config))
416
+ .catch(die)
396
417
  } else {
397
- poops()
418
+ poops().catch(die)
398
419
  }