poops 1.9.8 → 2.0.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.
@@ -34,10 +34,28 @@ const DEFAULTS = {
34
34
 
35
35
  const INTERNAL_FIELDS = new Set(['content', 'isIndex', 'layout', 'published', '_src'])
36
36
 
37
- function normalizeConfig(config) {
37
+ // Warned once per feature per process: llms is normalized twice (index + full),
38
+ // and a watch session would otherwise repeat the line on every rebuild.
39
+ const deprecationWarned = new Set()
40
+
41
+ function warnRenamedOutput(feature) {
42
+ if (deprecationWarned.has(feature)) return
43
+ deprecationWarned.add(feature)
44
+ log({ tag: 'indexer', warn: true, text: `Deprecated: "${feature}.output" is now "${feature}.out" — still read, will stop working in 3.0.` })
45
+ }
46
+
47
+ // `out` is the output key everywhere else in a poops config, so it is the one
48
+ // here too. 1.x spelled it `output` in these sub-features; that still works,
49
+ // warns, and goes away in 3.0.
50
+ function normalizeConfig(config, feature) {
38
51
  if (!config) return null
39
- if (typeof config === 'string') return { output: config, ...DEFAULTS }
40
- return { ...DEFAULTS, ...config }
52
+ if (typeof config === 'string') return { out: config, ...DEFAULTS }
53
+ const normalized = { ...DEFAULTS, ...config }
54
+ if (normalized.out === undefined && normalized.output !== undefined) {
55
+ warnRenamedOutput(feature)
56
+ normalized.out = normalized.output
57
+ }
58
+ return normalized
41
59
  }
42
60
 
43
61
  // A page front-matter `robots: noindex` (or `none`) opts it out of the
@@ -120,7 +138,7 @@ export function _getKeywordCache() {
120
138
  }
121
139
 
122
140
  export function generateSearchIndex(pageEntries, outputDir, config) {
123
- config = normalizeConfig(config)
141
+ config = normalizeConfig(config, 'markup.options.searchIndex')
124
142
  if (!config) return
125
143
 
126
144
  // Any option that changes extraction output invalidates the whole cache
@@ -159,14 +177,14 @@ export function generateSearchIndex(pageEntries, outputDir, config) {
159
177
 
160
178
  // resolve, not join: outputDir may be absolute (join would mangle it,
161
179
  // e.g. cross-drive temp dirs on Windows)
162
- const outputPath = path.resolve(process.cwd(), outputDir, config.output)
180
+ const outputPath = path.resolve(process.cwd(), outputDir, config.out)
163
181
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
164
182
  fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
165
183
  log({ tag: 'indexer', text: 'Generated search index:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
166
184
  }
167
185
 
168
186
  export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
169
- config = normalizeConfig(config)
187
+ config = normalizeConfig(config, 'markup.options.sitemap')
170
188
  if (!config) return
171
189
 
172
190
  const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
@@ -190,7 +208,7 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
190
208
 
191
209
  // resolve, not join: outputDir may be absolute (join would mangle it,
192
210
  // e.g. cross-drive temp dirs on Windows)
193
- const outputPath = path.resolve(process.cwd(), outputDir, config.output)
211
+ const outputPath = path.resolve(process.cwd(), outputDir, config.out)
194
212
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
195
213
  fs.writeFileSync(outputPath, xml)
196
214
  log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
@@ -204,7 +222,7 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
204
222
  // resolved from site data by the caller. Mirrors generateSitemap's page set —
205
223
  // isIndex (collection landing/pagination) pages are skipped.
206
224
  export function generateLlmsTxt(pageEntries, outputDir, siteUrl, config) {
207
- config = normalizeConfig(config)
225
+ config = normalizeConfig(config, 'markup.options.llms')
208
226
  if (!config) return
209
227
 
210
228
  const baseUrl = siteUrl ? siteUrl.replace(/\/+$/, '') : ''
@@ -273,7 +291,7 @@ export function generateLlmsTxt(pageEntries, outputDir, siteUrl, config) {
273
291
  }
274
292
  }
275
293
 
276
- const outputPath = path.resolve(process.cwd(), outputDir, config.output)
294
+ const outputPath = path.resolve(process.cwd(), outputDir, config.out)
277
295
  fs.writeFileSync(outputPath, out)
278
296
  log({ tag: 'indexer', text: 'Generated llms.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
279
297
  }
@@ -287,14 +305,14 @@ export function generateLlmsTxt(pageEntries, outputDir, siteUrl, config) {
287
305
  // ponytail: raw markdown, so unrendered {% %} tags / shortcodes in a body leak
288
306
  // through verbatim. Upgrade path if that bites: render + HTML→markdown the body.
289
307
  export function generateLlmsFull(pageEntries, outputDir, siteUrl, config) {
290
- config = normalizeConfig(config)
308
+ config = normalizeConfig(config, 'markup.options.llms')
291
309
  if (!config || !config.full) return
292
310
  // Default filename pairs with the index: llms.txt → llms-full.txt, ai.txt →
293
311
  // ai-full.txt (suffix the index's `output`, dir/ext preserved). A string
294
312
  // `full` overrides the path outright.
295
313
  let output = config.full
296
314
  if (typeof output !== 'string') {
297
- const p = path.parse(config.output || 'llms.txt')
315
+ const p = path.parse(config.out || 'llms.txt')
298
316
  output = path.join(p.dir, `${p.name}-full${p.ext}`)
299
317
  }
300
318
 
@@ -349,7 +367,7 @@ export function generateLlmsFull(pageEntries, outputDir, siteUrl, config) {
349
367
  // sitemap's filename, threaded in by generateIndexFiles.
350
368
  // ponytail: single user-agent group; add per-agent groups if a real need shows up.
351
369
  export function generateRobotsTxt(outputDir, siteUrl, config, sitemapOutput) {
352
- config = normalizeConfig(config)
370
+ config = normalizeConfig(config, 'markup.options.robots')
353
371
  if (!config) return
354
372
 
355
373
  const arr = (v) => v == null ? [] : (Array.isArray(v) ? v : [v])
@@ -372,7 +390,7 @@ export function generateRobotsTxt(outputDir, siteUrl, config, sitemapOutput) {
372
390
  }
373
391
  if (sitemapUrl) lines.push('', `Sitemap: ${sitemapUrl}`)
374
392
 
375
- const outputPath = path.resolve(process.cwd(), outputDir, config.output)
393
+ const outputPath = path.resolve(process.cwd(), outputDir, config.out)
376
394
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
377
395
  fs.writeFileSync(outputPath, lines.join('\n') + '\n')
378
396
  log({ tag: 'indexer', text: 'Generated robots.txt:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
@@ -509,14 +527,14 @@ export function buildNavTree(pageEntries, config = {}) {
509
527
  }
510
528
 
511
529
  export function generateNav(pageEntries, outputDir, config) {
512
- config = normalizeConfig(config)
530
+ config = normalizeConfig(config, 'markup.options.nav')
513
531
  if (!config) return
514
532
 
515
533
  const tree = buildNavTree(pageEntries, config)
516
534
 
517
535
  // resolve, not join: outputDir may be absolute (join would mangle it,
518
536
  // e.g. cross-drive temp dirs on Windows)
519
- const outputPath = path.resolve(process.cwd(), outputDir, config.output)
537
+ const outputPath = path.resolve(process.cwd(), outputDir, config.out)
520
538
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
521
539
  fs.writeFileSync(outputPath, JSON.stringify(tree, null, 2))
522
540
  log({ tag: 'indexer', text: 'Generated nav:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
@@ -541,10 +559,10 @@ function feedAuthorName(author) {
541
559
 
542
560
  // Normalizes the `feed` option into concrete per-collection specs. Accepts:
543
561
  // true | "feed.xml" → RSS for every collection
544
- // { output, type, limit, … } → those options for every collection
562
+ // { out, type, limit, … } → those options for every collection
545
563
  // { collection, … } → one named collection
546
564
  // [ {…}, {…} ] → an explicit list
547
- // `output` containing a slash is a full path under the output dir; a bare
565
+ // `out` containing a slash is a full path under the output dir; a bare
548
566
  // filename is placed in the collection's own folder (default "feed.xml").
549
567
  function resolveFeedSpecs(config, collectionNames, site) {
550
568
  if (!config) return []
@@ -552,15 +570,20 @@ function resolveFeedSpecs(config, collectionNames, site) {
552
570
  const specs = []
553
571
  for (let item of raw) {
554
572
  if (item === true) item = {}
555
- else if (typeof item === 'string') item = { output: item }
573
+ else if (typeof item === 'string') item = { out: item }
556
574
  if (!item || typeof item !== 'object') continue
557
575
 
576
+ if (item.out === undefined && item.output !== undefined) {
577
+ warnRenamedOutput('markup.options.feed')
578
+ item = { ...item, out: item.output }
579
+ }
580
+
558
581
  const targets = item.collection ? [item.collection] : collectionNames
559
582
  for (const name of targets) {
560
- const outName = item.output || 'feed.xml'
583
+ const outName = item.out || 'feed.xml'
561
584
  specs.push({
562
585
  collection: name,
563
- output: outName.includes('/') ? outName : `${name}/${outName}`,
586
+ out: outName.includes('/') ? outName : `${name}/${outName}`,
564
587
  type: item.type === 'atom' ? 'atom' : 'rss',
565
588
  limit: Number(item.limit) > 0 ? Number(item.limit) : 20,
566
589
  title: item.title || (site.title ? `${humanize(name)} | ${site.title}` : humanize(name)),
@@ -685,10 +708,10 @@ export function generateFeeds(pageEntries, outputDir, siteUrl, config, site = {}
685
708
  .sort((a, b) => new Date(b.date || 0) - new Date(a.date || 0))
686
709
  .slice(0, spec.limit)
687
710
 
688
- const ctx = { selfUrl: abs(spec.output), channelLink: abs(spec.collection), abs }
711
+ const ctx = { selfUrl: abs(spec.out), channelLink: abs(spec.collection), abs }
689
712
  const xml = spec.type === 'atom' ? buildAtomFeed(spec, items, ctx) : buildRssFeed(spec, items, ctx)
690
713
 
691
- const outputPath = path.resolve(process.cwd(), outputDir, spec.output)
714
+ const outputPath = path.resolve(process.cwd(), outputDir, spec.out)
692
715
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
693
716
  fs.writeFileSync(outputPath, xml)
694
717
  log({ tag: 'indexer', text: 'Generated feed:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
@@ -701,7 +724,7 @@ export function generateIndexFiles(pageEntries, outputDir, siteUrl, config) {
701
724
  generateLlmsTxt(pageEntries, outputDir, siteUrl, config.llms)
702
725
  generateLlmsFull(pageEntries, outputDir, siteUrl, config.llms)
703
726
  const sitemapOutput = config.sitemap
704
- ? (typeof config.sitemap === 'string' ? config.sitemap : config.sitemap.output)
727
+ ? (typeof config.sitemap === 'string' ? config.sitemap : (config.sitemap.out ?? config.sitemap.output))
705
728
  : null
706
729
  generateRobotsTxt(outputDir, siteUrl, config.robots, sitemapOutput)
707
730
  generateNav(pageEntries, outputDir, config.nav)
package/lib/markups.js CHANGED
@@ -25,32 +25,53 @@ export default class Markups {
25
25
  if (!moduleConfig.options) moduleConfig.options = {}
26
26
 
27
27
  // Determine engine — resolved in init(), builtin name or importable module
28
- this.engineName = moduleConfig.engine || moduleConfig.options.engine || 'nunjucks'
28
+ this.engineName = this.option('engine') || 'nunjucks'
29
29
  this.logTag = 'markup'
30
30
 
31
31
  // Normalize config
32
32
  this.markupIn = moduleConfig.in
33
33
  this.markupOut = moduleConfig.out || process.cwd()
34
- this.siteData = moduleConfig.site || moduleConfig.options.site || {}
35
- this.timeDateFormat = moduleConfig.options.timeDateFormat || moduleConfig.timeDateFormat
36
- this.collectionsConfig = moduleConfig.options.collections || moduleConfig.collections
37
- this.includePaths = moduleConfig.includePaths || moduleConfig.options.includePaths || []
38
- this.searchIndexConfig = moduleConfig.options.searchIndex || moduleConfig.searchIndex
39
- this.sitemapConfig = moduleConfig.options.sitemap || moduleConfig.sitemap
40
- this.llmsConfig = moduleConfig.options.llms || moduleConfig.llms
41
- this.robotsConfig = moduleConfig.options.robots || moduleConfig.robots
42
- this.feedConfig = moduleConfig.options.feed || moduleConfig.feed
43
- this.navConfig = moduleConfig.options.nav || moduleConfig.nav
44
- this.baseURL = moduleConfig.baseURL || moduleConfig.options.baseURL || null
45
-
46
- this.autoescape = moduleConfig.autoescape || moduleConfig.options.autoescape || false
47
- this.dataConfig = moduleConfig.data || moduleConfig.options.data
34
+ this.siteData = this.option('site') || {}
35
+ this.dateFormat = this.option('dateFormat') ?? this.deprecatedOption('timeDateFormat', 'dateFormat')
36
+ this.collectionsConfig = this.option('collections')
37
+ this.includePaths = this.option('includePaths') || []
38
+ this.searchIndexConfig = this.option('searchIndex')
39
+ this.sitemapConfig = this.option('sitemap')
40
+ this.llmsConfig = this.option('llms')
41
+ this.robotsConfig = this.option('robots')
42
+ this.feedConfig = this.option('feed')
43
+ this.navConfig = this.option('nav')
44
+ this.baseURL = this.option('baseURL') || null
45
+
46
+ this.autoescape = this.option('autoescape') || false
47
+ this.dataConfig = this.option('data')
48
48
 
49
49
  if (!moduleConfig.out) {
50
50
  moduleConfig.out = this.markupOut
51
51
  }
52
52
  }
53
53
 
54
+ // `markup.options` is the canonical home for every markup setting except the
55
+ // `in`/`out` entry itself — the same { in, out, options } shape scripts and
56
+ // styles entries have. 1.x read most of these at the `markup.` level too;
57
+ // that still works, warns once per key, and goes away in 3.0.
58
+ option(key) {
59
+ const moduleConfig = this.config.markup
60
+ if (moduleConfig.options[key] !== undefined) return moduleConfig.options[key]
61
+ if (moduleConfig[key] === undefined) return undefined
62
+ log({ tag: 'markup', warn: true, text: `Deprecated: "markup.${key}" moved into "markup.options.${key}" in 2.0 — still read, will stop working in 3.0.` })
63
+ return moduleConfig[key]
64
+ }
65
+
66
+ // A key that was renamed in 2.0. Reads the old name (wherever it sits) and
67
+ // says what to call it now.
68
+ deprecatedOption(key, replacement) {
69
+ const value = this.option(key)
70
+ if (value === undefined) return undefined
71
+ log({ tag: 'markup', warn: true, text: `Deprecated: "${key}" is now "${replacement}" — still read, will stop working in 3.0.` })
72
+ return value
73
+ }
74
+
54
75
  // Engine instantiation is async because non-builtin engines are loaded via
55
76
  // dynamic import. Idempotent; compile() calls it lazily, so a failed engine
56
77
  // resolution logs once per compile and the module stays inert.
@@ -86,7 +107,7 @@ export default class Markups {
86
107
  autoescape: this.autoescape
87
108
  })
88
109
 
89
- this.engine.registerFilters({ timeDateFormat: this.timeDateFormat, markupOut: this.markupOut })
110
+ this.engine.registerFilters({ dateFormat: this.dateFormat, markupOut: this.markupOut })
90
111
  this.engine.registerTags(() => path.resolve(process.cwd(), this.markupOut))
91
112
 
92
113
  // Load global variables
@@ -97,10 +118,6 @@ export default class Markups {
97
118
 
98
119
  this.engine.setGlobal('site', this.siteData)
99
120
 
100
- if (this.config.livereload_port) {
101
- this.engine.setGlobal('livereload_port', this.config.livereload_port)
102
- }
103
-
104
121
  if (this.config.reactorData) {
105
122
  for (const [name, html] of Object.entries(this.config.reactorData)) {
106
123
  this.engine.setGlobal(name, html)
@@ -114,7 +131,7 @@ export default class Markups {
114
131
  // title/description from site data so generateLlmsTxt gets ready values.
115
132
  resolveLlmsConfig() {
116
133
  if (!this.llmsConfig) return undefined
117
- const base = typeof this.llmsConfig === 'string' ? { output: this.llmsConfig } : { ...this.llmsConfig }
134
+ const base = typeof this.llmsConfig === 'string' ? { out: this.llmsConfig } : { ...this.llmsConfig }
118
135
  base.title = base.title || this.siteData.title
119
136
  base.description = base.description || this.siteData.description
120
137
  return base
package/lib/reactor.js CHANGED
@@ -6,7 +6,8 @@ import {
6
6
  pathContainsPathSegment,
7
7
  fillBannerTemplate,
8
8
  buildTime,
9
- fileSize
9
+ fileSize,
10
+ DEFAULT_TARGET
10
11
  } from './utils/helpers.js'
11
12
  import minifyToFile from './utils/minify.js'
12
13
  import fs from 'node:fs'
@@ -100,7 +101,7 @@ export const html = renderToString(React.createElement(Component));
100
101
  sourcemap: false,
101
102
  minify: false,
102
103
  format: 'iife',
103
- target: 'es2019',
104
+ target: DEFAULT_TARGET,
104
105
  nodePaths: this.config.includePaths
105
106
  }
106
107
 
package/lib/scripts.js CHANGED
@@ -15,7 +15,8 @@ import {
15
15
  fillOutTemplateEntry,
16
16
  fillBannerTemplate,
17
17
  buildTime,
18
- fileSize
18
+ fileSize,
19
+ DEFAULT_TARGET
19
20
  } from './utils/helpers.js'
20
21
  import path from 'node:path'
21
22
  import minifyToFile from './utils/minify.js'
@@ -93,7 +94,7 @@ export default class Scripts {
93
94
  sourcemap: false,
94
95
  minify: false,
95
96
  format: 'iife',
96
- target: 'es2019',
97
+ target: DEFAULT_TARGET,
97
98
  nodePaths: this.config.includePaths // Resolve `includePaths`
98
99
  }
99
100
 
package/lib/server.js CHANGED
@@ -37,6 +37,80 @@ const MIME_TYPES = {
37
37
  '.webmanifest': 'application/manifest+json; charset=utf-8'
38
38
  }
39
39
 
40
+ // The reload channel. One endpoint on the static server, so a poops dev
41
+ // session is one port total.
42
+ export const RELOAD_PATH = '/__poops_reload'
43
+
44
+ // Injected into every served HTML page while livereload is on, so nothing has
45
+ // to be embedded in your templates. EventSource reconnects on its own, which
46
+ // is the whole reason this is SSE and not a socket: restart poops and the open
47
+ // tabs re-attach without a keepalive protocol of our own.
48
+ // CSS paths arrive as build-output paths ('dist/css/site.css') while the page
49
+ // links a URL ('/css/site.css'), so a stylesheet matches when the emitted path
50
+ // ends with the link's pathname — the same suffix match livereload did, and it
51
+ // survives any `serve.base`. Re-setting href with a cache-busting query swaps
52
+ // the stylesheet in place; the page never reloads and scroll/state survive.
53
+ // A css event that matches no link on the page falls back to a full reload:
54
+ // the build wrote a stylesheet this page does not link (or links under another
55
+ // name), and silently doing nothing would look like a dead watcher.
56
+ const RELOAD_CLIENT = `<script>
57
+ (function () {
58
+ var es = new EventSource('${RELOAD_PATH}')
59
+ es.addEventListener('reload', function () { location.reload() })
60
+ es.addEventListener('css', function (e) {
61
+ var paths = JSON.parse(e.data)
62
+ var links = document.querySelectorAll('link[rel="stylesheet"][href]')
63
+ var swapped = 0
64
+ for (var i = 0; i < links.length; i++) {
65
+ var pathname = new URL(links[i].href, location.href).pathname
66
+ for (var j = 0; j < paths.length; j++) {
67
+ if (paths[j].endsWith(pathname)) {
68
+ links[i].href = pathname + '?poops=' + Date.now()
69
+ swapped++
70
+ break
71
+ }
72
+ }
73
+ }
74
+ if (!swapped) location.reload()
75
+ })
76
+ })()
77
+ </script>
78
+ `
79
+
80
+ export function injectReloadClient(html) {
81
+ return /<\/body>/i.test(html)
82
+ ? html.replace(/<\/body>/i, RELOAD_CLIENT + '</body>')
83
+ : html + RELOAD_CLIENT
84
+ }
85
+
86
+ // Holds the open EventSource responses and fans build events out to them.
87
+ // Kept separate from the request handler so the watch loop can own one hub and
88
+ // hand it to the server, and so it can be driven directly in tests.
89
+ export function createReloadHub() {
90
+ const clients = new Set()
91
+
92
+ return {
93
+ clients,
94
+
95
+ handle(req, res) {
96
+ res.writeHead(200, {
97
+ 'Content-Type': 'text/event-stream',
98
+ 'Cache-Control': 'no-cache',
99
+ Connection: 'keep-alive'
100
+ })
101
+ // Reconnect fast after a poops restart — the browser default is 3s
102
+ res.write('retry: 1000\n\n')
103
+ clients.add(res)
104
+ req.on('close', () => clients.delete(res))
105
+ },
106
+
107
+ send(event, data) {
108
+ const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`
109
+ for (const res of clients) res.write(payload)
110
+ }
111
+ }
112
+ }
113
+
40
114
  // Parse a single-range `Range: bytes=start-end` header against a file size.
41
115
  // Returns { start, end } (inclusive), null when absent/ignorable (multipart,
42
116
  // malformed — RFC says serve the full file then), or 'unsatisfiable' for a
@@ -62,7 +136,7 @@ export function parseRange(header, size) {
62
136
  return { start, end }
63
137
  }
64
138
 
65
- export function createStaticHandler(base) {
139
+ export function createStaticHandler(base, reloadHub) {
66
140
  // Drop any trailing separator — `serve.base: "/"` resolves to `<cwd>/`, and the
67
141
  // containment check below would then compare against `<cwd>//` and reject everything
68
142
  base = path.resolve(base)
@@ -81,6 +155,7 @@ export function createStaticHandler(base) {
81
155
  return res.end('Not Found')
82
156
  }
83
157
  if (!/<base[\s>]/i.test(html)) html = html.replace(/<head([^>]*)>/i, '<head$1><base href="/">')
158
+ if (reloadHub) html = injectReloadClient(html)
84
159
  res.end(html)
85
160
  }
86
161
 
@@ -100,6 +175,8 @@ export function createStaticHandler(base) {
100
175
  return res.end('Bad Request')
101
176
  }
102
177
 
178
+ if (reloadHub && pathname === RELOAD_PATH) return reloadHub.handle(req, res)
179
+
103
180
  // Contain resolution inside `base` — rejects `..` traversal and null bytes
104
181
  if (pathname.includes('\0')) return notFound(res)
105
182
  let filePath = path.normalize(path.join(base, pathname))
@@ -133,7 +210,24 @@ export function createStaticHandler(base) {
133
210
  }
134
211
  }
135
212
 
136
- res.setHeader('Content-Type', MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream')
213
+ const ext = path.extname(filePath).toLowerCase()
214
+ res.setHeader('Content-Type', MIME_TYPES[ext] || 'application/octet-stream')
215
+
216
+ // HTML gets the reload client appended, so it's built in memory rather than
217
+ // streamed — the length changes, and a Range over a page nobody seeks in
218
+ // isn't worth keeping consistent with the injection.
219
+ if (reloadHub && ext === '.html') {
220
+ let html
221
+ try {
222
+ html = injectReloadClient(fs.readFileSync(filePath, 'utf8'))
223
+ } catch {
224
+ return notFound(res)
225
+ }
226
+ res.statusCode = 200
227
+ res.setHeader('Content-Length', Buffer.byteLength(html))
228
+ return res.end(req.method === 'HEAD' ? undefined : html)
229
+ }
230
+
137
231
  res.setHeader('Accept-Ranges', 'bytes')
138
232
 
139
233
  const range = parseRange(req.headers.range, stat.size)
package/lib/styles.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  entryBase,
12
12
  hasOutTemplate,
13
13
  fillOutTemplate,
14
+ insertMinSuffix,
14
15
  fillBannerTemplate,
15
16
  buildTime,
16
17
  fileSize
@@ -127,7 +128,9 @@ export default class Styles {
127
128
  const mapsrc = options.sourcemap ? `\n/*# sourceMappingURL=${path.basename(outfilePath)}.map */` : ''
128
129
  if (this.banner) compiledSass.css = this.banner + '\n' + compiledSass.css
129
130
  fs.writeFileSync(outfilePath, compiledSass.css + mapsrc)
130
- this.outputs.push(outfilePath) // after the write — a failed compile has nothing to reload
131
+ // after the write — a failed compile has nothing to reload. `justMinified`
132
+ // deletes this file below, so only the .min path is worth reporting then.
133
+ if (!options.justMinified) this.outputs.push(outfilePath)
131
134
  const stylesEnd = performance.now()
132
135
  if (!options.justMinified) log({ tag: 'style', text: 'Compiled:', link: outfilePath, size: fileSize(outfilePath), time: buildTime(stylesStart, stylesEnd) })
133
136
 
@@ -146,5 +149,11 @@ export default class Styles {
146
149
  options,
147
150
  startTime: options.justMinified ? stylesStart : undefined
148
151
  })
152
+
153
+ // Pages link whichever spelling they were written against, and with
154
+ // `minify` on that is usually the .min file. Reporting only the plain one
155
+ // meant the reload client could find no matching stylesheet and fell back
156
+ // to reloading the whole page on every style edit.
157
+ if (options.minify) this.outputs.push(insertMinSuffix(outfilePath))
149
158
  }
150
159
  }
@@ -4,6 +4,12 @@ import path from 'node:path'
4
4
  import yaml from 'yaml'
5
5
  import { convertGlobToRegex } from 'book-of-spells'
6
6
 
7
+ // The esbuild target scripts and reactor bundles compile to when an entry
8
+ // doesn't set its own. ES2020 is where optional chaining and nullish
9
+ // coalescing live — the syntax people write today, which an older target
10
+ // would lower into helpers for browsers that have supported it since 2020.
11
+ export const DEFAULT_TARGET = 'es2020'
12
+
7
13
  export function toPosix(filePath) {
8
14
  return path.sep === '/' ? filePath : filePath.split(path.sep).join('/')
9
15
  }
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.9.8",
4
+ "version": "2.0.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
@@ -51,18 +51,17 @@
51
51
  "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js"
52
52
  },
53
53
  "engines": {
54
- "node": ">=20"
54
+ "node": ">=22"
55
55
  },
56
56
  "dependencies": {
57
57
  "argoyle": "^1.0.0",
58
- "book-of-spells": "^1.3.0",
59
- "chokidar": "^3.5.3",
58
+ "book-of-spells": "^1.4.0",
59
+ "chokidar": "^5.0.0",
60
60
  "dayjs": "^1.11.19",
61
- "esbuild": "^0.25.0",
61
+ "esbuild": "^0.28.1",
62
62
  "glob": "^13.0.6",
63
63
  "highlight.js": "^11.11.1",
64
64
  "liquidjs": "^10.24.0",
65
- "livereload": "^0.9.3",
66
65
  "marked": "^18.0.6",
67
66
  "marked-github-alerts": "^1.0.1",
68
67
  "marked-github-emoji": "^1.0.2",
@@ -79,15 +78,17 @@
79
78
  "@tailwindcss/postcss": "^4.2.1",
80
79
  "eslint": "^9.39.3",
81
80
  "jest": "^30.2.0",
82
- "neostandard": "^0.12.2",
81
+ "neostandard": "^0.13.0",
83
82
  "poops-docs-theme": "^1.0.0",
84
- "postcss": "^8.5.8",
83
+ "postcss": "^8.5.25",
85
84
  "react": "^19.2.4",
86
85
  "react-dom": "^19.2.4",
87
- "sulphuris": "^2.0.0",
86
+ "sulphuris": "^4.0.0",
88
87
  "tailwindcss": "^4.2.1"
89
88
  },
90
89
  "overrides": {
91
- "minimatch": ">=10.2.1"
90
+ "nunjucks": {
91
+ "chokidar": "$chokidar"
92
+ }
92
93
  }
93
94
  }