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 +33 -11
- package/lib/copy.js +5 -3
- package/lib/markup/engines/liquid.js +25 -1
- package/lib/markup/engines/nunjucks.js +3 -2
- package/lib/markup/indexer.js +7 -1
- package/lib/markups.js +14 -3
- package/lib/server.js +146 -0
- package/lib/utils/helpers.js +32 -2
- package/package.json +5 -5
- package/poops.js +52 -31
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# 💩 Poops [](https://www.npmjs.com/package/poops)
|
|
1
|
+
# 💩 Poops [](https://www.npmjs.com/package/poops) [](https://github.com/stamat/poops/actions/workflows/ci.yml) [](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
|
|
788
|
-
path: tag
|
|
789
|
-
paginate: 5
|
|
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
|
|
875
|
-
|
|
876
|
-
|
|
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` →
|
|
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
|
-
|
|
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 }}"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
19
|
-
|
|
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.
|
package/lib/markup/indexer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/utils/helpers.js
CHANGED
|
@@ -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.
|
|
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": "
|
|
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') //
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
}
|