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.
- package/README.md +122 -112
- package/lib/markup/collections.js +4 -1
- package/lib/markup/engines/liquid.js +2 -2
- package/lib/markup/engines/nunjucks.js +2 -2
- package/lib/markup/image-cache.js +30 -2
- package/lib/markup/indexer.js +46 -23
- package/lib/markups.js +38 -21
- package/lib/reactor.js +3 -2
- package/lib/scripts.js +3 -2
- package/lib/server.js +96 -2
- package/lib/styles.js +10 -1
- package/lib/utils/helpers.js +6 -0
- package/package.json +11 -10
- package/poops.js +47 -48
package/lib/markup/indexer.js
CHANGED
|
@@ -34,10 +34,28 @@ const DEFAULTS = {
|
|
|
34
34
|
|
|
35
35
|
const INTERNAL_FIELDS = new Set(['content', 'isIndex', 'layout', 'published', '_src'])
|
|
36
36
|
|
|
37
|
-
|
|
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 {
|
|
40
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
// {
|
|
562
|
+
// { out, type, limit, … } → those options for every collection
|
|
545
563
|
// { collection, … } → one named collection
|
|
546
564
|
// [ {…}, {…} ] → an explicit list
|
|
547
|
-
// `
|
|
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 = {
|
|
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.
|
|
583
|
+
const outName = item.out || 'feed.xml'
|
|
561
584
|
specs.push({
|
|
562
585
|
collection: name,
|
|
563
|
-
|
|
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.
|
|
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.
|
|
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 =
|
|
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 =
|
|
35
|
-
this.
|
|
36
|
-
this.collectionsConfig =
|
|
37
|
-
this.includePaths =
|
|
38
|
-
this.searchIndexConfig =
|
|
39
|
-
this.sitemapConfig =
|
|
40
|
-
this.llmsConfig =
|
|
41
|
-
this.robotsConfig =
|
|
42
|
-
this.feedConfig =
|
|
43
|
-
this.navConfig =
|
|
44
|
-
this.baseURL =
|
|
45
|
-
|
|
46
|
-
this.autoescape =
|
|
47
|
-
this.dataConfig =
|
|
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({
|
|
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' ? {
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/utils/helpers.js
CHANGED
|
@@ -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": "
|
|
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": ">=
|
|
54
|
+
"node": ">=22"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"argoyle": "^1.0.0",
|
|
58
|
-
"book-of-spells": "^1.
|
|
59
|
-
"chokidar": "^
|
|
58
|
+
"book-of-spells": "^1.4.0",
|
|
59
|
+
"chokidar": "^5.0.0",
|
|
60
60
|
"dayjs": "^1.11.19",
|
|
61
|
-
"esbuild": "^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.
|
|
81
|
+
"neostandard": "^0.13.0",
|
|
83
82
|
"poops-docs-theme": "^1.0.0",
|
|
84
|
-
"postcss": "^8.5.
|
|
83
|
+
"postcss": "^8.5.25",
|
|
85
84
|
"react": "^19.2.4",
|
|
86
85
|
"react-dom": "^19.2.4",
|
|
87
|
-
"sulphuris": "^
|
|
86
|
+
"sulphuris": "^4.0.0",
|
|
88
87
|
"tailwindcss": "^4.2.1"
|
|
89
88
|
},
|
|
90
89
|
"overrides": {
|
|
91
|
-
"
|
|
90
|
+
"nunjucks": {
|
|
91
|
+
"chokidar": "$chokidar"
|
|
92
|
+
}
|
|
92
93
|
}
|
|
93
94
|
}
|