poops 1.2.3 → 1.3.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/lib/styles.js CHANGED
@@ -1,14 +1,13 @@
1
- import { transform } from 'esbuild'
2
1
  import fs from 'node:fs'
3
2
  import {
4
3
  pathExists,
5
4
  mkPath,
6
- insertMinSuffix,
7
5
  buildStyleOutputFilePath,
8
6
  fillBannerTemplate,
9
7
  buildTime,
10
8
  fileSize
11
9
  } from './utils/helpers.js'
10
+ import minifyToFile from './utils/minify.js'
12
11
  import path from 'node:path'
13
12
  import * as sass from 'sass'
14
13
  import log from './utils/log.js'
@@ -25,10 +24,13 @@ export default class Styles {
25
24
  if (!this.config.styles) return
26
25
  this.config.styles = Array.isArray(this.config.styles) ? this.config.styles : [this.config.styles]
27
26
  for (const styleEntry of this.config.styles) {
28
- if (styleEntry.in && styleEntry.out && pathExists(styleEntry.in)) {
29
- mkPath(styleEntry.out)
30
- await this.compileEntry(styleEntry.in, styleEntry.out, styleEntry.options)
27
+ if (!styleEntry.in || !styleEntry.out) continue
28
+ if (!pathExists(styleEntry.in)) {
29
+ log({ tag: 'style', error: true, text: 'Entry does not exist:', link: styleEntry.in })
30
+ continue
31
31
  }
32
+ mkPath(styleEntry.out)
33
+ await this.compileEntry(styleEntry.in, styleEntry.out, styleEntry.options)
32
34
  }
33
35
  }
34
36
 
@@ -55,7 +57,7 @@ export default class Styles {
55
57
  opts.sourceMapIncludeSources = options.sourcemap
56
58
  }
57
59
 
58
- outfilePath = buildStyleOutputFilePath(infilePath, outfilePath, options)
60
+ outfilePath = buildStyleOutputFilePath(infilePath, outfilePath)
59
61
 
60
62
  const stylesStart = performance.now()
61
63
  let compiledSass
@@ -79,30 +81,14 @@ export default class Styles {
79
81
  log({ tag: 'style', text: 'Compiled:', link: `${outfilePath}.map` })
80
82
  }
81
83
 
82
- const minPath = insertMinSuffix(outfilePath)
83
- if (options.minify) {
84
- try {
85
- const stylesMinStart = !options.justMinified ? performance.now() : stylesStart
86
-
87
- const minified = await transform(compiledSass.css, {
88
- loader: 'css',
89
- minify: true
90
- })
91
-
92
- if (this.banner) minified.code = this.banner + '\n' + minified.code
93
- fs.writeFileSync(minPath, minified.code)
94
- const stylesMinEnd = performance.now()
95
- log({ tag: 'style', text: 'Compiled:', link: minPath, size: fileSize(minPath), time: buildTime(stylesMinStart, stylesMinEnd) })
96
- } catch (err) {
97
- log({ tag: 'style', error: true, text: 'Failed compiling:', link: minPath })
98
- console.error(err)
99
- }
100
-
101
- if (options.justMinified) {
102
- fs.unlinkSync(outfilePath)
103
- }
104
- } else {
105
- if (pathExists(minPath)) fs.unlinkSync(minPath)
106
- }
84
+ await minifyToFile({
85
+ outfilePath,
86
+ loader: 'css',
87
+ code: compiledSass.css,
88
+ banner: this.banner,
89
+ tag: 'style',
90
+ options,
91
+ startTime: options.justMinified ? stylesStart : undefined
92
+ })
107
93
  }
108
94
  }
@@ -4,6 +4,10 @@ import path from 'node:path'
4
4
  import yaml from 'yaml'
5
5
  import { convertGlobToRegex } from 'book-of-spells'
6
6
 
7
+ export function toPosix(filePath) {
8
+ return path.sep === '/' ? filePath : filePath.split(path.sep).join('/')
9
+ }
10
+
7
11
  export function pathExists() {
8
12
  return fs.existsSync(path.join(...arguments))
9
13
  }
@@ -36,13 +40,6 @@ export function buildStyleOutputFilePath(inputPath, outputPath) {
36
40
  return path.join(path.join(outputPath, `${name}.css`))
37
41
  }
38
42
 
39
- export function buildScriptOutputFilePath(inputPath, outputPath) {
40
- if (pathForFile(outputPath)) return outputPath
41
- const { name, ext } = path.parse(inputPath)
42
- const outExt = /\.(tsx?|jsx)$/i.test(ext) ? '.js' : ext
43
- return path.join(outputPath, `${name}${outExt}`)
44
- }
45
-
46
43
  export function fillBannerTemplate(template, packagesPath) {
47
44
  packagesPath = packagesPath || process.cwd()
48
45
  const packagesFilePath = path.join(packagesPath, 'package.json')
@@ -63,13 +60,14 @@ export function fillBannerTemplate(template, packagesPath) {
63
60
 
64
61
  export function buildTime(start, end) {
65
62
  const time = Math.round(end - start)
66
- if (time < 1000) return `${time}ms`
67
- const seconds = Math.floor(time / 1000)
63
+ const minutes = Math.floor(time / 60000)
64
+ const seconds = Math.floor(time / 1000) % 60
68
65
  const ms = time % 1000
69
- if (time < 60 * 1000) return `${seconds}s ${ms}ms`
70
- const minutes = Math.floor(seconds / 60)
71
- const remainingSeconds = seconds % 60
72
- return `${minutes}m ${remainingSeconds}s ${ms}ms`
66
+ const parts = []
67
+ if (minutes) parts.push(`${minutes}m`)
68
+ if (seconds) parts.push(`${seconds}s`)
69
+ if (ms) parts.push(`${ms}ms`)
70
+ return parts.join(' ') || '0ms'
73
71
  }
74
72
 
75
73
  export function fileSize(filePath) {
@@ -77,8 +75,12 @@ export function fileSize(filePath) {
77
75
  const fileSizeInBytes = stats.size
78
76
  if (fileSizeInBytes < 1000) return `${fileSizeInBytes}B`
79
77
  if (fileSizeInBytes < 1000 * 1000) return `${(fileSizeInBytes / 1000).toFixed(0)}KB`
80
- if (fileSizeInBytes < 1000 * 1000 * 1000) return `${Math.floor(fileSizeInBytes / 1000 / 1000)}MB ${Math.floor((fileSizeInBytes % (1000 * 1000)) / 1000)}KB`
81
- return fileSizeInBytes
78
+ if (fileSizeInBytes < 1000 * 1000 * 1000) {
79
+ const kb = Math.floor((fileSizeInBytes % (1000 * 1000)) / 1000)
80
+ return `${Math.floor(fileSizeInBytes / 1000 / 1000)}MB${kb ? ` ${kb}KB` : ''}`
81
+ }
82
+ const mb = Math.floor((fileSizeInBytes % (1000 * 1000 * 1000)) / 1000 / 1000)
83
+ return `${Math.floor(fileSizeInBytes / 1000 / 1000 / 1000)}GB${mb ? ` ${mb}MB` : ''}`
82
84
  }
83
85
 
84
86
  export function readJsonFile(filePath) {
@@ -146,10 +148,12 @@ export function copyDirectory(src, dest) {
146
148
  }
147
149
 
148
150
  export function stripDirNavSegments(filePath) {
149
- return path.normalize(filePath).replace(/(\.\.\/|\.\/|\/\.\.|\.\.\\|\.\\|\\\.\.)/g, '')
151
+ return toPosix(path.normalize(filePath)).replace(/(\.\.\/|\.\/|\/\.\.|\.\.\\|\.\\|\\\.\.)/g, '')
150
152
  }
151
153
 
152
154
  export function pathContainsPathSegment(filePath, segment) {
155
+ // Watcher paths arrive with native separators, config segments with `/`
156
+ filePath = toPosix(filePath)
153
157
  segment = stripDirNavSegments(segment)
154
158
  if (hasMagic(segment)) {
155
159
  segment = convertGlobToRegex(segment)
package/lib/utils/log.js CHANGED
@@ -8,6 +8,7 @@ const TAG_COLORS = {
8
8
  style: 'magentaBright',
9
9
  markup: 'cyanBright',
10
10
  copy: 'green',
11
+ image: 'greenBright',
11
12
  info: 'blue',
12
13
  error: 'redBright'
13
14
  }
@@ -18,18 +19,31 @@ const DEFAULT_COLOR = 'white'
18
19
  * @param {Object} options
19
20
  * @param {string} options.tag - Tag label (e.g. 'script', 'style', 'markup', 'reactor', 'copy', 'info')
20
21
  * @param {boolean} [options.error] - Whether this is an error message
22
+ * @param {boolean} [options.warn] - Whether this is a warning (does not fail the build)
21
23
  * @param {string} options.text - Main message text
22
24
  * @param {string} [options.link] - File path or URL to display underlined
23
25
  * @param {string} [options.size] - File size string
24
26
  * @param {string} [options.time] - Build time string
25
27
  */
26
- export default function log({ tag, error, text, link, size, time }) {
28
+ let errorLogged = false
29
+
30
+ // Build mode exits non-zero if any module reported an error, even ones
31
+ // swallowed internally (log-and-continue). log() is the single point
32
+ // every module error passes through, so the flag lives here.
33
+ export function hasLoggedErrors() {
34
+ return errorLogged
35
+ }
36
+
37
+ export default function log({ tag, error, warn, text, link, size, time }) {
27
38
  if (tag === 'error') error = true
39
+ if (error) errorLogged = true
28
40
  const color = TAG_COLORS[tag] || DEFAULT_COLOR
29
41
  let msg = pstyle.paint(`{${color}.bold|[${tag}]}`)
30
42
 
31
43
  if (error) {
32
44
  msg += pstyle.paint(' {redBright.bold|[error]}')
45
+ } else if (warn) {
46
+ msg += pstyle.paint(' {yellowBright.bold|[warn]}')
33
47
  }
34
48
 
35
49
  msg += pstyle.paint(` {dim|${text}}`)
@@ -0,0 +1,41 @@
1
+ import { transform } from 'esbuild'
2
+ import fs from 'node:fs'
3
+ import { pathExists, insertMinSuffix, fileSize, buildTime } from './helpers.js'
4
+ import log from './log.js'
5
+
6
+ /**
7
+ * Shared post-compile minify step: writes `<name>.min<ext>` next to
8
+ * `outfilePath`, deletes the unminified file when `options.justMinified`,
9
+ * or deletes a stale `.min` file when minify is off.
10
+ *
11
+ * @param {Object} params
12
+ * @param {string} params.outfilePath - Compiled (unminified) output file
13
+ * @param {'js'|'css'} params.loader - esbuild transform loader
14
+ * @param {string} [params.code] - Source to minify; read from outfilePath when omitted
15
+ * @param {string} [params.banner] - Banner prepended to the minified code
16
+ * @param {string} params.tag - Log tag
17
+ * @param {Object} [params.options] - Entry options (`minify`, `justMinified`)
18
+ * @param {number} [params.startTime] - Timing start override (justMinified logs include compile time)
19
+ */
20
+ export default async function minifyToFile({ outfilePath, loader, code, banner, tag, options = {}, startTime }) {
21
+ const minPath = insertMinSuffix(outfilePath)
22
+
23
+ if (!options.minify) {
24
+ if (pathExists(minPath)) fs.unlinkSync(minPath)
25
+ return
26
+ }
27
+
28
+ try {
29
+ const start = startTime ?? performance.now()
30
+ if (code == null) code = fs.readFileSync(outfilePath, 'utf-8')
31
+ const minified = await transform(code, { loader, minify: true })
32
+ if (banner) minified.code = banner + '\n' + minified.code
33
+ fs.writeFileSync(minPath, minified.code)
34
+ log({ tag, text: 'Compiled:', link: minPath, size: fileSize(minPath), time: buildTime(start, performance.now()) })
35
+ } catch (err) {
36
+ log({ tag, error: true, text: 'Failed compiling:', link: minPath })
37
+ console.error(err)
38
+ }
39
+
40
+ if (options.justMinified) fs.unlinkSync(outfilePath)
41
+ }
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.2.3",
4
+ "version": "1.3.0",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
@@ -34,16 +34,20 @@
34
34
  "static-site-generator"
35
35
  ],
36
36
  "peerDependencies": {
37
- "postcss": "^8.0.0"
37
+ "postcss": "^8.0.0",
38
+ "poops-images": ">=1.2.0"
38
39
  },
39
40
  "peerDependenciesMeta": {
40
41
  "postcss": {
41
42
  "optional": true
43
+ },
44
+ "poops-images": {
45
+ "optional": true
42
46
  }
43
47
  },
44
48
  "scripts": {
45
49
  "build": "node ./poops.js -b",
46
- "lint": "eslint ./poops.js",
50
+ "lint": "eslint .",
47
51
  "test": "NODE_OPTIONS='--experimental-vm-modules' jest"
48
52
  },
49
53
  "dependencies": {
package/poops.js CHANGED
@@ -3,17 +3,18 @@
3
3
  import chokidar from 'chokidar'
4
4
  import connect from 'connect'
5
5
  import Copy from './lib/copy.js'
6
- import { pathExists, doesFileBelongToPath } from './lib/utils/helpers.js'
6
+ import { pathExists, doesFileBelongToPath, pathContainsPathSegment } from './lib/utils/helpers.js'
7
7
  import http from 'node:http'
8
8
  import os from 'node:os'
9
9
  import fs from 'node:fs'
10
10
  import livereload from 'livereload'
11
11
  import Markups from './lib/markups.js'
12
+ import Images from './lib/images.js'
12
13
  import path from 'node:path'
13
14
  import serveStatic from 'serve-static'
14
15
  import Reactor from './lib/reactor.js'
15
16
  import Scripts from './lib/scripts.js'
16
- import log, { styledLog } from './lib/utils/log.js'
17
+ import log, { styledLog, hasLoggedErrors } from './lib/utils/log.js'
17
18
  import Styles from './lib/styles.js'
18
19
  import PostCSS from './lib/postcss.js'
19
20
  import Argoyle from 'argoyle'
@@ -72,7 +73,9 @@ function setupLiveReloadServer(config) {
72
73
 
73
74
  const liveReloadServer = livereload.createServer({
74
75
  exclusions: [...new Set(liveReloadExcludes)],
75
- port: config.livereload_port
76
+ port: config.livereload_port,
77
+ exts: config.livereload.exts, // replaces the default watched extensions
78
+ extraExts: config.livereload.extraExts // adds to the defaults
76
79
  })
77
80
  styledLog(`🔃 {dim}LiveReload :{/} ${liveReloadServer.config.port}`)
78
81
  console.log()
@@ -82,16 +85,27 @@ function setupLiveReloadServer(config) {
82
85
  function setupWatchers(config, modules) {
83
86
  if (!config.watch) return
84
87
 
85
- // TODO: think about watching the updates of the config file itself, we can reload the config and recompile everything.
86
- // TODO: ability to automatically create a watch list of directories if watch is set to true. The list will be generated from the `in` property of each task.
87
88
  // awaitWriteFinish: wait for saves to finish writing before recompiling, so a
88
89
  // mid-write (truncated/partial) file is never read. Fixes intermittent broken
89
- // builds on editor save. ponytail: default thresholds are fine; bump if slow disks flake.
90
- chokidar.watch(config.watch, {
91
- ignoreInitial: true,
92
- awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
93
- }).on('change', (file) => {
94
- if (/(\.m?jsx?|\.tsx?)$/i.test(file)) {
90
+ // builds on editor save. Bump thresholds if slow disks flake.
91
+ // Shared by 'change' and 'add': editors with atomic saves (rename-write)
92
+ // fire unlink+add instead of change, so both events must trigger the same
93
+ // rebuilds. 'add' also covers genuinely new files (e.g. a new markup page).
94
+ // Rebuild branches shared by change/add/deletion: a deletion needs the same
95
+ // rebuilds (a deleted-but-still-imported file must surface the error).
96
+ // Scripts/styles outputs can land inside a watched dir (e.g. a Shopify
97
+ // theme's assets/): the compiler's own write must not retrigger that
98
+ // compiler, or watch loops forever. Zones are the dirs the compilers write
99
+ // into; only their own extensions are skipped there, so e.g. a hand-edited
100
+ // .liquid asset in the same dir still rebuilds markup.
101
+ const outputZones = [config.scripts, config.styles].flat()
102
+ .filter((entry) => entry && entry.out)
103
+ .map((entry) => (path.extname(entry.out) ? path.dirname(entry.out) : entry.out))
104
+ .filter((zone) => zone && zone !== '.')
105
+ const isBuildOutput = (file) => outputZones.some((zone) => pathContainsPathSegment(file, zone))
106
+
107
+ const rebuild = (file) => {
108
+ if (/(\.m?jsx?|\.tsx?)$/i.test(file) && !isBuildOutput(file)) {
95
109
  modules.scripts.compile().catch(err => console.error(err))
96
110
 
97
111
  if (modules.reactor.belongsToReactor(file)) {
@@ -103,33 +117,86 @@ function setupWatchers(config, modules) {
103
117
  }).catch(err => console.error(err))
104
118
  }
105
119
  }
106
- if (/(\.sass|\.scss|\.css)$/i.test(file)) {
120
+ if (/(\.sass|\.scss|\.css)$/i.test(file) && !isBuildOutput(file)) {
107
121
  modules.styles.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
108
122
  }
109
123
  if (/(\.html|\.xml|\.rss|\.atom|\.njk|\.liquid|\.md)$/i.test(file)) {
110
124
  modules.markups.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
111
125
  }
112
126
 
113
- // TODO: We can actually reload the page only if the data file from data has changed.
114
127
  if (/(\.json|\.ya?ml)$/i.test(file)) {
115
128
  modules.markups.reloadDataFiles().then(() => modules.markups.compile()).catch(err => console.error(err))
116
129
  }
130
+ }
117
131
 
132
+ // Source image extensions handled by poops-images. The doesFileBelongToPath
133
+ // guard (against config.images.in) also breaks the feedback loop: generated
134
+ // variants land in the `out` dir, never in `in`, so they don't retrigger.
135
+ const imageExtRe = /(\.jpe?g|\.png|\.tiff?|\.webp|\.heic|\.heif|\.svg|\.gif)$/i
136
+ const belongsToImages = (file) => imageExtRe.test(file) && doesFileBelongToPath(file, config.images)
137
+
138
+ const compileChanged = (file) => {
139
+ rebuild(file)
140
+ if (belongsToImages(file)) {
141
+ modules.images.compile()
142
+ .then(() => modules.markups.compile())
143
+ .then(() => modules.postcss.compile())
144
+ .catch(err => console.error(err))
145
+ }
118
146
  doesFileBelongToPath(file, config.copy) && modules.copy.execute().catch(err => console.error(err))
119
- }).on('unlink', (file) => {
120
- if (/(\.html|\.xml|\.rss|\.atom|\.njk|\.liquid|\.md)$/i.test(file)) {
121
- modules.markups.compile().catch(err => console.error(err))
147
+ }
148
+
149
+ // Atomic-save editors (rename-write) fire unlink+add for every save, so an
150
+ // unlink only counts as a real deletion if no add for the same path follows
151
+ // within the settle window. Must outlive the 150ms awaitWriteFinish delay
152
+ // on 'add'. Fixed 300ms; make configurable if slow disks flake.
153
+ const UNLINK_SETTLE_MS = 300
154
+ const pendingUnlinks = new Map()
155
+
156
+ const scheduleUnlink = (target, handler) => {
157
+ clearTimeout(pendingUnlinks.get(target))
158
+ pendingUnlinks.set(target, setTimeout(() => {
159
+ pendingUnlinks.delete(target)
160
+ handler(target)
161
+ }, UNLINK_SETTLE_MS))
162
+ }
163
+
164
+ const handleDeleted = (file) => {
165
+ modules.markups.removeOutput(file)
166
+ if (belongsToImages(file)) {
167
+ // Deleted source: drop its variants + cache entry, then recompile markup
168
+ // so galleries/srcsets reading the image cache no longer reference it.
169
+ modules.images.remove(file)
170
+ .then(() => modules.markups.compile())
171
+ .then(() => modules.postcss.compile())
172
+ .catch(err => console.error(err))
122
173
  }
174
+ rebuild(file)
123
175
  modules.copy.unlink(file, doesFileBelongToPath(file, config.copy))
124
- }).on('unlinkDir', (dirPath) => {
125
- doesFileBelongToPath(dirPath, config.markup) && modules.markups.compile().catch(err => console.error(err))
126
- modules.copy.unlink(dirPath, doesFileBelongToPath(dirPath, config.copy))
127
- }).on('add', (file) => {
128
- if (/(\.json|\.ya?ml)$/i.test(file)) {
129
- modules.markups.reloadDataFiles().then(() => modules.markups.compile()).catch(err => console.error(err))
176
+ }
177
+
178
+ const handleDeletedDir = (dirPath) => {
179
+ modules.markups.removeOutput(dirPath)
180
+ if (doesFileBelongToPath(dirPath, config.markup)) {
181
+ modules.markups.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
130
182
  }
131
- doesFileBelongToPath(file, config.copy) && modules.copy.execute().catch(err => console.error(err))
132
- })
183
+ modules.copy.unlink(dirPath, doesFileBelongToPath(dirPath, config.copy))
184
+ }
185
+
186
+ chokidar.watch(config.watch, {
187
+ ignoreInitial: true,
188
+ awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50 }
189
+ }).on('change', compileChanged)
190
+ .on('add', (file) => {
191
+ const pending = pendingUnlinks.get(file)
192
+ if (pending) {
193
+ clearTimeout(pending)
194
+ pendingUnlinks.delete(file)
195
+ }
196
+ compileChanged(file)
197
+ })
198
+ .on('unlink', (file) => scheduleUnlink(file, handleDeleted))
199
+ .on('unlinkDir', (dirPath) => scheduleUnlink(dirPath, handleDeletedDir))
133
200
  }
134
201
 
135
202
  // Main function 💩
@@ -138,22 +205,32 @@ async function poops() {
138
205
  const postcss = new PostCSS(config)
139
206
  const reactor = new Reactor(config)
140
207
  const scripts = new Scripts(config)
208
+ const images = new Images(config)
141
209
  const markups = new Markups(config)
142
210
  const copy = new Copy(config)
143
211
 
144
- try { await styles.compile() } catch (err) { console.error(err) }
145
- try { await reactor.compile() } catch (err) { console.error(err) }
212
+ // Thrown errors are caught so one failing step doesn't stop the rest;
213
+ // `failed` + hasLoggedErrors() (module-internal, swallowed errors) decide
214
+ // the build exit code, so a broken compile can't ship as a green build.
215
+ let failed = false
216
+ const step = async(task) => {
217
+ try { await task() } catch (err) { failed = true; console.error(err) }
218
+ }
219
+
220
+ await step(() => styles.compile())
221
+ await step(() => reactor.compile())
146
222
  config.reactorData = reactor.getRendered()
147
- try { await scripts.compile() } catch (err) { console.error(err) }
148
- try { await markups.compile() } catch (err) { console.error(err) }
149
- try { await postcss.compile() } catch (err) { console.error(err) }
150
- try { await copy.execute() } catch (err) { console.error(err) }
223
+ await step(() => scripts.compile())
224
+ await step(() => images.compile()) // before markups: engines read the poops-images cache
225
+ await step(() => markups.compile())
226
+ await step(() => postcss.compile())
227
+ await step(() => copy.execute())
151
228
 
152
229
  if (build || (!config.watch && !config.livereload && !config.serve)) {
153
- process.exit(0)
230
+ process.exit(failed || hasLoggedErrors() ? 1 : 0)
154
231
  }
155
232
 
156
- setupWatchers(config, { styles, postcss, reactor, scripts, markups, copy })
233
+ setupWatchers(config, { styles, postcss, reactor, scripts, images, markups, copy })
157
234
  }
158
235
 
159
236
  // CLI Header
@@ -191,6 +268,12 @@ if (overrideBaseURL && config.markup) {
191
268
  config.markup.baseURL = overrideBaseURL
192
269
  }
193
270
 
271
+ // poops-images resolves custom handlers/composites relative to the config file;
272
+ // without this it would default to cwd. Matches poops-images' own loadConfig.
273
+ if (config.images && typeof config.images === 'object' && config.images.configDir === undefined) {
274
+ config.images.configDir = path.dirname(configPath)
275
+ }
276
+
194
277
  async function getAvailablePort(port, max) {
195
278
  while (port < max) {
196
279
  const status = await portscanner.checkPortStatus(port, 'localhost')
@@ -250,6 +333,11 @@ async function startServer() {
250
333
  // Start the webserver
251
334
  if (!build && config.serve) {
252
335
  startServer()
336
+ } else if (!build && config.livereload) {
337
+ // livereload without serve: still needs the livereload server
338
+ resolveLiveReloadPort(config)
339
+ .then(poops)
340
+ .then(() => setupLiveReloadServer(config))
253
341
  } else {
254
342
  poops()
255
343
  }