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/markups.js CHANGED
@@ -1,12 +1,14 @@
1
- import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime } from './utils/helpers.js'
2
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
1
+ import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
2
+ import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
3
3
  import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
4
- import { generateIndexFiles } from './markup/indexer.js'
4
+ import { generateIndexFiles, buildNavTree } from './markup/indexer.js'
5
5
  import NunjucksEngine from './markup/engines/nunjucks.js'
6
6
  import LiquidEngine from './markup/engines/liquid.js'
7
7
  import fs from 'node:fs'
8
8
  import { globSync } from 'glob'
9
9
  import path from 'node:path'
10
+ import { createRequire } from 'node:module'
11
+ import { pathToFileURL } from 'node:url'
10
12
  import log from './utils/log.js'
11
13
 
12
14
  const ENGINES = {
@@ -22,8 +24,8 @@ export default class Markups {
22
24
  if (!moduleConfig || !moduleConfig.in) return
23
25
  if (!moduleConfig.options) moduleConfig.options = {}
24
26
 
25
- // Determine engine
26
- const engineName = moduleConfig.engine || moduleConfig.options.engine || 'nunjucks'
27
+ // Determine engine — resolved in init(), builtin name or importable module
28
+ this.engineName = moduleConfig.engine || moduleConfig.options.engine || 'nunjucks'
27
29
  this.logTag = 'markup'
28
30
 
29
31
  // Normalize config
@@ -35,23 +37,54 @@ export default class Markups {
35
37
  this.includePaths = moduleConfig.includePaths || moduleConfig.options.includePaths || []
36
38
  this.searchIndexConfig = moduleConfig.options.searchIndex || moduleConfig.searchIndex
37
39
  this.sitemapConfig = moduleConfig.options.sitemap || moduleConfig.sitemap
40
+ this.navConfig = moduleConfig.options.nav || moduleConfig.nav
38
41
  this.baseURL = moduleConfig.baseURL || moduleConfig.options.baseURL || null
39
- this.dataFiles = []
40
42
 
41
- // Instantiate engine
42
- const EngineClass = ENGINES[engineName]
43
+ this.autoescape = moduleConfig.autoescape || moduleConfig.options.autoescape || false
44
+ this.dataConfig = moduleConfig.data || moduleConfig.options.data
45
+
46
+ if (!moduleConfig.out) {
47
+ moduleConfig.out = this.markupOut
48
+ }
49
+ }
50
+
51
+ // Engine instantiation is async because non-builtin engines are loaded via
52
+ // dynamic import. Idempotent; compile() calls it lazily, so a failed engine
53
+ // resolution logs once per compile and the module stays inert.
54
+ async init() {
55
+ if (!this.markupIn || this.engine) return
56
+
57
+ let EngineClass = ENGINES[this.engineName]
43
58
  if (!EngineClass) {
44
- log({ tag: 'error', text: `Unknown markup engine: ${engineName}` })
59
+ try {
60
+ // Relative/absolute specifiers resolve against cwd (the user's
61
+ // project), bare specifiers against the module graph (node_modules)
62
+ const spec = /^[./]/.test(this.engineName) || path.isAbsolute(this.engineName)
63
+ ? pathToFileURL(path.resolve(process.cwd(), this.engineName)).href
64
+ : this.engineName
65
+ EngineClass = (await import(spec)).default
66
+ } catch {
67
+ // Bare specifier not reachable from poops's own module graph (e.g.
68
+ // poops installed via a file: link) — resolve from the user's project
69
+ try {
70
+ const projectRequire = createRequire(path.join(process.cwd(), 'package.json'))
71
+ EngineClass = (await import(pathToFileURL(projectRequire.resolve(this.engineName)).href)).default
72
+ } catch { /* handled below */ }
73
+ }
74
+ }
75
+
76
+ if (typeof EngineClass !== 'function') {
77
+ log({ tag: 'error', text: `Unknown markup engine: ${this.engineName}` })
45
78
  return
46
79
  }
47
80
 
48
- const templatesDir = path.join(process.cwd(), this.markupIn)
81
+ const templatesDir = path.resolve(process.cwd(), this.markupIn)
49
82
  this.engine = new EngineClass(templatesDir, this.includePaths, {
50
- autoescape: moduleConfig.autoescape || moduleConfig.options.autoescape || false
83
+ autoescape: this.autoescape
51
84
  })
52
85
 
53
86
  this.engine.registerFilters({ timeDateFormat: this.timeDateFormat, markupOut: this.markupOut })
54
- this.engine.registerTags(() => path.join(process.cwd(), this.markupOut))
87
+ this.engine.registerTags(() => path.resolve(process.cwd(), this.markupOut))
55
88
 
56
89
  // Load global variables
57
90
  const pkgPath = path.join(process.cwd(), 'package.json')
@@ -71,12 +104,7 @@ export default class Markups {
71
104
  }
72
105
  }
73
106
 
74
- const data = moduleConfig.data || moduleConfig.options.data
75
- this.loadDataFiles(data)
76
-
77
- if (!moduleConfig.out) {
78
- moduleConfig.out = this.markupOut
79
- }
107
+ this.loadDataFiles(this.dataConfig)
80
108
  }
81
109
 
82
110
  loadDataFiles(files) {
@@ -90,36 +118,79 @@ export default class Markups {
90
118
  const dataDir = pathIsDirectory(this.markupIn) ? this.markupIn : path.dirname(this.markupIn)
91
119
  const resolved = []
92
120
  for (const file of files) {
93
- const fullPath = path.join(process.cwd(), dataDir, file)
121
+ const fullPath = path.resolve(process.cwd(), dataDir, file)
94
122
  if (pathIsDirectory(fullPath)) {
95
- const dirFiles = globSync(path.join(fullPath, '**/*.+(json|yml|yaml)'))
123
+ const dirFiles = globSync(toPosix(path.join(fullPath, '**/*.+(json|yml|yaml)')))
96
124
  for (const f of dirFiles) {
97
- resolved.push(path.relative(path.join(process.cwd(), dataDir), f))
125
+ resolved.push(path.relative(path.resolve(process.cwd(), dataDir), f))
98
126
  }
99
127
  } else {
100
128
  resolved.push(file)
101
129
  }
102
130
  }
103
131
 
104
- if (!this.dataFiles.length) this.dataFiles = resolved
105
-
132
+ const loadedKeys = new Set()
106
133
  for (const dataFile of resolved) {
107
134
  try {
108
- const data = readDataFile(path.join(process.cwd(), dataDir, dataFile))
135
+ const data = readDataFile(path.resolve(process.cwd(), dataDir, dataFile))
109
136
  const globalKeyName = path.basename(dataFile, path.extname(dataFile)).replace(/[.\-\s]/g, '_')
110
137
  this.engine.setGlobal(globalKeyName, data)
138
+ loadedKeys.add(globalKeyName)
111
139
  } catch (err) {
112
140
  log({ tag: 'error', text: 'Data file not found:', link: dataFile })
113
141
  continue
114
142
  }
115
143
  }
144
+
145
+ // A deleted data file must also drop its global, or stale data renders forever
146
+ if (this.dataGlobalKeys) {
147
+ for (const key of this.dataGlobalKeys) {
148
+ if (!loadedKeys.has(key)) this.engine.removeGlobal(key)
149
+ }
150
+ }
151
+ this.dataGlobalKeys = loadedKeys
116
152
  }
117
153
 
118
154
  reloadDataFiles() {
119
- this.loadDataFiles(this.dataFiles)
155
+ if (this.engine) this.loadDataFiles(this.dataConfig)
120
156
  return Promise.resolve()
121
157
  }
122
158
 
159
+ // Engines can override output extension mapping (e.g. a template format
160
+ // whose extension isn't in the default map); falls back to the shared helper
161
+ mapOutputPath(outputPath) {
162
+ if (this.engine && typeof this.engine.replaceOutExtensions === 'function') {
163
+ return this.engine.replaceOutExtensions(outputPath)
164
+ }
165
+ return replaceOutExtensions(outputPath)
166
+ }
167
+
168
+ // Maps a deleted source path to its build output and removes it.
169
+ // compile() only globs existing sources, so it can never clean up
170
+ // after a deletion — this is the only place stale output gets removed.
171
+ removeOutput(sourcePath) {
172
+ if (!this.markupIn) return
173
+ const markupIn = path.resolve(process.cwd(), this.markupIn)
174
+ const rel = path.relative(markupIn, path.resolve(process.cwd(), sourcePath))
175
+ if (rel.startsWith('..') || path.isAbsolute(rel)) return
176
+
177
+ const mapped = path.resolve(process.cwd(), this.markupOut, rel)
178
+
179
+ // rel === '' with a directory output would be the whole out dir —
180
+ // never remove that, it also holds css/js from other modules
181
+ if (rel !== '' && fs.existsSync(mapped) && fs.statSync(mapped).isDirectory()) {
182
+ fs.rmSync(mapped, { recursive: true })
183
+ log({ tag: this.logTag, text: 'Removed:', link: path.relative(process.cwd(), mapped) })
184
+ return
185
+ }
186
+
187
+ const outFile = this.mapOutputPath(mapped)
188
+ if (fs.existsSync(outFile) && !fs.statSync(outFile).isDirectory()) {
189
+ fs.unlinkSync(outFile)
190
+ log({ tag: this.logTag, text: 'Removed:', link: path.relative(process.cwd(), outFile) })
191
+ }
192
+ }
193
+
123
194
  generateMarkupGlobPattern(excludes) {
124
195
  let markupDefaultExcludes = ['node_modules', '.git', '.svn', '.hg']
125
196
 
@@ -170,12 +241,72 @@ export default class Markups {
170
241
  return { result, frontMatter }
171
242
  }
172
243
 
244
+ getMarkupFiles(markupIn) {
245
+ // glob patterns must use `/` — on Windows `\` is an escape character
246
+ return [
247
+ ...globSync(toPosix(path.join(markupIn, this.generateMarkupGlobPattern(this.includePaths)))),
248
+ ...globSync(toPosix(path.join(markupIn, `*.+(${this.engine.markupExtensions})`)))
249
+ ]
250
+ }
251
+
252
+ // True for collection index templates that compileDirectory skips —
253
+ // pagination renders them instead
254
+ isCollectionIndexOverride(relativePathParts, collectionData) {
255
+ return relativePathParts.length > 1 &&
256
+ collectionData[relativePathParts[0]] &&
257
+ relativePathParts[1].startsWith('index.') &&
258
+ this.engine.indexableExtensions.has(path.extname(relativePathParts[1])) &&
259
+ collectionData[relativePathParts[0]].items.length > 0
260
+ }
261
+
262
+ // Pre-pass: builds the nav tree from front matter alone (no rendering) and
263
+ // exposes it as the `nav` template global, so sidebars can render during the
264
+ // same compile instead of reading the previous build's nav.json.
265
+ // parseFrontMatter caches by mtime, so the compile pass re-reads nothing.
266
+ buildNavGlobal(markupIn, collectionData) {
267
+ if (!this.navConfig) return
268
+
269
+ const entries = []
270
+ for (const collectionName of Object.keys(collectionData)) {
271
+ entries.push({ url: collectionName, title: collectionName, isIndex: true })
272
+ }
273
+
274
+ const files = pathIsDirectory(markupIn) ? this.getMarkupFiles(markupIn) : [markupIn]
275
+ for (const file of files) {
276
+ if (!this.engine.indexableExtensions.has(path.extname(file))) continue
277
+ const relativePath = path.relative(markupIn, file)
278
+ const relativePathParts = relativePath.split(path.sep)
279
+ if (this.isCollectionIndexOverride(relativePathParts, collectionData)) continue
280
+
281
+ let frontMatter
282
+ try {
283
+ frontMatter = parseFrontMatter(file).frontMatter
284
+ } catch (err) {
285
+ continue
286
+ }
287
+ if (frontMatter.published === false) continue
288
+ if (!frontMatter.title) frontMatter.title = path.basename(file, path.extname(file))
289
+
290
+ const fileCollection = relativePathParts.length > 1 && collectionData[relativePathParts[0]]
291
+ ? relativePathParts[0]
292
+ : null
293
+ if (fileCollection && !frontMatter.collection) frontMatter.collection = fileCollection
294
+
295
+ const outPath = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
296
+ entries.push({
297
+ ...frontMatter,
298
+ url: getPageUrlRelativeToOutput(outPath, this.markupOut),
299
+ isIndex: false
300
+ })
301
+ }
302
+
303
+ const navOptions = typeof this.navConfig === 'object' ? this.navConfig : {}
304
+ this.engine.setGlobal('nav', buildNavTree(entries, navOptions))
305
+ }
306
+
173
307
  async compileDirectory(markupIn, collectionData, pageEntries) {
174
308
  const markupStart = performance.now()
175
- const markupFiles = [
176
- ...globSync(path.join(markupIn, this.generateMarkupGlobPattern(this.includePaths))),
177
- ...globSync(path.join(markupIn, `*.+(${this.engine.markupExtensions})`))
178
- ]
309
+ const markupFiles = this.getMarkupFiles(markupIn)
179
310
  const compilePromises = []
180
311
  const indexableExtensions = this.engine.indexableExtensions
181
312
 
@@ -183,15 +314,14 @@ export default class Markups {
183
314
  const relativePath = path.relative(markupIn, file)
184
315
  const relativePathParts = relativePath.split(path.sep)
185
316
 
186
- if (relativePathParts.length > 1 &&
187
- collectionData[relativePathParts[0]] &&
188
- relativePathParts[1].startsWith('index.') && indexableExtensions.has(path.extname(relativePathParts[1])) &&
189
- collectionData[relativePathParts[0]].items.length > 0) {
317
+ if (this.isCollectionIndexOverride(relativePathParts, collectionData)) {
190
318
  continue
191
319
  }
192
320
 
193
- let markupOut = path.join(process.cwd(), this.markupOut, relativePath)
194
- const fromPath = path.join(process.cwd(), this.markupOut)
321
+ // Map before deriving dir/prefix: engines may relocate output (e.g.
322
+ // Shopify's templates/ flattening), and links must match the final path.
323
+ const markupOut = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
324
+ const fromPath = path.resolve(process.cwd(), this.markupOut)
195
325
  const markupOutDir = path.dirname(markupOut)
196
326
 
197
327
  mkDir(markupOutDir)
@@ -199,7 +329,9 @@ export default class Markups {
199
329
  const fileContext = {
200
330
  ...collectionData,
201
331
  relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
202
- _url: getPageUrl(markupOut)
332
+ // output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
333
+ // otherwise `item.url == page.url` sidebar/active checks never fire
334
+ _url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
203
335
  }
204
336
 
205
337
  const shouldIndex = pageEntries && indexableExtensions.has(path.extname(file))
@@ -209,14 +341,12 @@ export default class Markups {
209
341
 
210
342
  const compilePromise = this.compileEntry(file, fileContext).then(({ result, frontMatter, skipped }) => {
211
343
  if (skipped) {
212
- const outFile = replaceOutExtensions(markupOut)
213
- if (fs.existsSync(outFile)) {
214
- fs.unlinkSync(outFile)
215
- log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), outFile) })
344
+ if (fs.existsSync(markupOut)) {
345
+ fs.unlinkSync(markupOut)
346
+ log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), markupOut) })
216
347
  }
217
348
  return
218
349
  }
219
- markupOut = replaceOutExtensions(markupOut)
220
350
  fs.writeFileSync(markupOut, result)
221
351
 
222
352
  if (shouldIndex && frontMatter.published !== false) {
@@ -249,7 +379,7 @@ export default class Markups {
249
379
 
250
380
  async compileSingleFile(markupIn, collectionData, pageEntries) {
251
381
  const markupStart = performance.now()
252
- let markupOut = path.join(process.cwd(), this.markupOut)
382
+ let markupOut = path.resolve(process.cwd(), this.markupOut)
253
383
  const markupOutDir = path.dirname(markupOut)
254
384
  const indexableExtensions = this.engine.indexableExtensions
255
385
  mkDir(markupOutDir)
@@ -257,14 +387,15 @@ export default class Markups {
257
387
  const fileContext = {
258
388
  ...collectionData,
259
389
  relativePathPrefix: getRelativePathPrefix(markupOutDir, null, this.baseURL),
260
- _url: getPageUrl(markupOut)
390
+ // output-relative so page.url matches nav.json/index urls, same as compileDirectory
391
+ _url: getPageUrlRelativeToOutput(this.mapOutputPath(markupOut), this.markupOut)
261
392
  }
262
393
 
263
394
  const shouldIndex = pageEntries && indexableExtensions.has(path.extname(markupIn))
264
395
 
265
396
  try {
266
397
  const { result, frontMatter, skipped } = await this.compileEntry(markupIn, fileContext)
267
- markupOut = replaceOutExtensions(markupOut)
398
+ markupOut = this.mapOutputPath(markupOut)
268
399
 
269
400
  if (skipped) {
270
401
  if (fs.existsSync(markupOut)) {
@@ -287,9 +418,9 @@ export default class Markups {
287
418
  }
288
419
 
289
420
  const markupEnd = performance.now()
290
- log({ tag: this.logTag, text: 'Compiled:', link: path.relative(process.cwd(), path.join(process.cwd(), this.markupOut, path.basename(markupIn))), time: buildTime(markupStart, markupEnd) })
421
+ log({ tag: this.logTag, text: 'Compiled:', link: path.relative(process.cwd(), path.resolve(process.cwd(), this.markupOut, path.basename(markupIn))), time: buildTime(markupStart, markupEnd) })
291
422
  } catch (err) {
292
- log({ tag: this.logTag, error: true, text: 'Failed compiling:', link: path.relative(process.cwd(), path.join(process.cwd(), this.markupOut, path.basename(markupIn))) })
423
+ log({ tag: this.logTag, error: true, text: 'Failed compiling:', link: path.relative(process.cwd(), path.resolve(process.cwd(), this.markupOut, path.basename(markupIn))) })
293
424
  console.error(err)
294
425
  throw err
295
426
  } finally {
@@ -301,13 +432,26 @@ export default class Markups {
301
432
  const moduleConfig = this.config.markup
302
433
  if (!moduleConfig || !moduleConfig.in) return
303
434
 
435
+ await this.init()
436
+ if (!this.engine) return
437
+
304
438
  if (this.config.reactorData) {
439
+ // A removed reactor component must also drop its injected global,
440
+ // same staleness rule as data files in loadDataFiles()
441
+ const reactorKeys = new Set(Object.keys(this.config.reactorData))
442
+ if (this.reactorGlobalKeys) {
443
+ for (const key of this.reactorGlobalKeys) {
444
+ if (!reactorKeys.has(key)) this.engine.removeGlobal(key)
445
+ }
446
+ }
447
+ this.reactorGlobalKeys = reactorKeys
448
+
305
449
  for (const [name, html] of Object.entries(this.config.reactorData)) {
306
450
  this.engine.setGlobal(name, html)
307
451
  }
308
452
  }
309
453
 
310
- const markupIn = path.join(process.cwd(), this.markupIn)
454
+ const markupIn = path.resolve(process.cwd(), this.markupIn)
311
455
 
312
456
  if (!pathExists(markupIn)) {
313
457
  log({ tag: 'error', text: 'Markup path does not exist:', link: markupIn })
@@ -319,10 +463,15 @@ export default class Markups {
319
463
  ...getCollectionDataBasedOnConfig(this.markupIn, this.collectionsConfig)
320
464
  }
321
465
 
322
- const shouldIndex = this.searchIndexConfig || this.sitemapConfig
466
+ const shouldIndex = this.searchIndexConfig || this.sitemapConfig || this.navConfig
323
467
  const pageEntries = shouldIndex ? [] : null
324
468
 
325
469
  buildCollectionPaginationData(collectionData)
470
+
471
+ // must precede any rendering (incl. pagination pages) so every template
472
+ // sees the current build's nav
473
+ this.buildNavGlobal(markupIn, collectionData)
474
+
326
475
  const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL)
327
476
 
328
477
  await Promise.all(collectionPromises)
@@ -352,7 +501,8 @@ export default class Markups {
352
501
  if (shouldIndex && pageEntries) {
353
502
  generateIndexFiles(pageEntries, this.markupOut, this.siteData.url, {
354
503
  searchIndex: this.searchIndexConfig,
355
- sitemap: this.sitemapConfig
504
+ sitemap: this.sitemapConfig,
505
+ nav: this.navConfig
356
506
  })
357
507
  }
358
508
  }
package/lib/postcss.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 log from './utils/log.js'
13
12
 
14
13
  let postcss
@@ -65,10 +64,13 @@ export default class PostCSS {
65
64
 
66
65
  this.config.postcss = Array.isArray(this.config.postcss) ? this.config.postcss : [this.config.postcss]
67
66
  for (const entry of this.config.postcss) {
68
- if (entry.in && entry.out && pathExists(entry.in)) {
69
- mkPath(entry.out)
70
- await this.compileEntry(entry.in, entry.out, entry.options)
67
+ if (!entry.in || !entry.out) continue
68
+ if (!pathExists(entry.in)) {
69
+ log({ tag: 'postcss', error: true, text: 'Entry does not exist:', link: entry.in })
70
+ continue
71
71
  }
72
+ mkPath(entry.out)
73
+ await this.compileEntry(entry.in, entry.out, entry.options)
72
74
  }
73
75
  }
74
76
 
@@ -98,30 +100,14 @@ export default class PostCSS {
98
100
  const end = performance.now()
99
101
  if (!options.justMinified) log({ tag: 'postcss', text: 'Compiled:', link: outfilePath, size: fileSize(outfilePath), time: buildTime(start, end) })
100
102
 
101
- const minPath = insertMinSuffix(outfilePath)
102
- if (options.minify) {
103
- try {
104
- const minStart = !options.justMinified ? performance.now() : start
105
-
106
- const minified = await transform(css, {
107
- loader: 'css',
108
- minify: true
109
- })
110
-
111
- if (this.banner) minified.code = this.banner + '\n' + minified.code
112
- fs.writeFileSync(minPath, minified.code)
113
- const minEnd = performance.now()
114
- log({ tag: 'postcss', text: 'Compiled:', link: minPath, size: fileSize(minPath), time: buildTime(minStart, minEnd) })
115
- } catch (err) {
116
- log({ tag: 'postcss', error: true, text: 'Failed compiling:', link: minPath })
117
- console.error(err)
118
- }
119
-
120
- if (options.justMinified) {
121
- fs.unlinkSync(outfilePath)
122
- }
123
- } else {
124
- if (pathExists(minPath)) fs.unlinkSync(minPath)
125
- }
103
+ await minifyToFile({
104
+ outfilePath,
105
+ loader: 'css',
106
+ code: css,
107
+ banner: this.banner,
108
+ tag: 'postcss',
109
+ options,
110
+ startTime: options.justMinified ? start : undefined
111
+ })
126
112
  }
127
113
  }
package/lib/reactor.js CHANGED
@@ -1,14 +1,14 @@
1
- import { build, transform } from 'esbuild'
1
+ import { build } from 'esbuild'
2
2
  import { deepMerge } from 'book-of-spells'
3
3
  import {
4
4
  pathExists,
5
5
  mkPath,
6
- insertMinSuffix,
7
6
  pathContainsPathSegment,
8
7
  fillBannerTemplate,
9
8
  buildTime,
10
9
  fileSize
11
10
  } from './utils/helpers.js'
11
+ import minifyToFile from './utils/minify.js'
12
12
  import fs from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { createRequire } from 'node:module'
@@ -29,9 +29,12 @@ export default class Reactor {
29
29
  this.rendered = {}
30
30
 
31
31
  for (const entry of this.config.reactor) {
32
- if (entry.component && entry.inject && pathExists(entry.component)) {
33
- await this.compileEntry(entry)
32
+ if (!entry.component || !entry.inject) continue
33
+ if (!pathExists(entry.component)) {
34
+ log({ tag: 'reactor', error: true, text: 'Component does not exist:', link: entry.component })
35
+ continue
34
36
  }
37
+ await this.compileEntry(entry)
35
38
  }
36
39
 
37
40
  this.renderedChanged = JSON.stringify(this.rendered) !== JSON.stringify(prevRendered)
@@ -83,6 +86,9 @@ export const html = renderToString(React.createElement(Component));
83
86
  }
84
87
 
85
88
  // --- Step 2: Bundle the client entry for the browser ---
89
+ if (client && out && !pathExists(client)) {
90
+ log({ tag: 'reactor', error: true, text: 'Entry does not exist:', link: client })
91
+ }
86
92
  if (client && out && pathExists(client)) {
87
93
  mkPath(out)
88
94
 
@@ -129,31 +135,7 @@ export const html = renderToString(React.createElement(Component));
129
135
  if (!options.justMinified) log({ tag: 'reactor', text: 'Compiled:', link: out, size: fileSize(out), time: buildTime(esbuildStart, esbuildEnd) })
130
136
  if (options.sourcemap) log({ tag: 'reactor', text: 'Compiled:', link: `${out}.map` })
131
137
 
132
- if (options.minify) {
133
- const minPath = insertMinSuffix(out)
134
- try {
135
- const terserStart = performance.now()
136
- const minifyResult = await transform(fs.readFileSync(out, 'utf-8'), {
137
- minify: true,
138
- loader: 'js'
139
- })
140
- const terserEnd = performance.now()
141
-
142
- if (this.banner) minifyResult.code = this.banner + '\n' + minifyResult.code
143
- fs.writeFileSync(minPath, minifyResult.code)
144
- log({ tag: 'reactor', text: 'Compiled:', link: minPath, size: fileSize(minPath), time: buildTime(terserStart, terserEnd) })
145
- } catch (err) {
146
- log({ tag: 'reactor', error: true, text: 'Failed compiling:', link: minPath })
147
- console.error(err)
148
- }
149
-
150
- if (options.justMinified) {
151
- fs.unlinkSync(out)
152
- }
153
- } else {
154
- const minPath = insertMinSuffix(out)
155
- if (pathExists(minPath)) fs.unlinkSync(minPath)
156
- }
138
+ await minifyToFile({ outfilePath: out, loader: 'js', banner: this.banner, tag: 'reactor', options })
157
139
  }
158
140
  }
159
141
 
package/lib/scripts.js CHANGED
@@ -1,16 +1,15 @@
1
- import { build, transform } from 'esbuild'
1
+ import { build } from 'esbuild'
2
+ import { globSync, hasMagic } from 'glob'
2
3
  import { deepMerge } from 'book-of-spells'
3
4
  import {
4
5
  pathExists,
5
6
  mkPath,
6
7
  pathForFile,
7
- insertMinSuffix,
8
- buildScriptOutputFilePath,
9
8
  fillBannerTemplate,
10
9
  buildTime,
11
10
  fileSize
12
11
  } from './utils/helpers.js'
13
- import fs from 'node:fs'
12
+ import minifyToFile from './utils/minify.js'
14
13
  import log from './utils/log.js'
15
14
 
16
15
  export default class Scripts {
@@ -24,10 +23,30 @@ export default class Scripts {
24
23
  this.config.scripts = Array.isArray(this.config.scripts) ? this.config.scripts : [this.config.scripts]
25
24
 
26
25
  for (const scriptEntry of this.config.scripts) {
27
- if (scriptEntry.in && scriptEntry.out && pathExists(scriptEntry.in)) {
28
- mkPath(scriptEntry.out)
29
- await this.compileEntry(scriptEntry.in, scriptEntry.out, scriptEntry.options)
26
+ if (!scriptEntry.in || !scriptEntry.out) continue
27
+ // `in` may be an array of entry points — pathExists on an array throws
28
+ const configured = Array.isArray(scriptEntry.in) ? scriptEntry.in : [scriptEntry.in]
29
+ const entryPoints = []
30
+ let missing = false
31
+ for (const entry of configured) {
32
+ if (hasMagic(entry)) {
33
+ // Globs must use `/` even on Windows; sort for deterministic build order
34
+ const matches = globSync(entry, { posix: true }).sort()
35
+ if (!matches.length) {
36
+ log({ tag: 'script', error: true, text: 'Entry does not exist:', link: entry })
37
+ missing = true
38
+ }
39
+ entryPoints.push(...matches)
40
+ } else if (!pathExists(entry)) {
41
+ log({ tag: 'script', error: true, text: 'Entry does not exist:', link: entry })
42
+ missing = true
43
+ } else {
44
+ entryPoints.push(entry)
45
+ }
30
46
  }
47
+ if (missing) continue
48
+ mkPath(scriptEntry.out)
49
+ await this.compileEntry(entryPoints, scriptEntry.out, scriptEntry.options)
31
50
  }
32
51
  }
33
52
 
@@ -73,9 +92,16 @@ export default class Scripts {
73
92
 
74
93
  deepMerge(opts, optionsClone) // ability to pass other esbuild options `node_modules/esbuild/lib/main.d.ts`
75
94
 
95
+ // Multi-dir entry points nest output under their common ancestor (esbuild's
96
+ // outbase), so output paths can't be derived from basenames — read them
97
+ // from the metafile instead. Keys are relative to absWorkingDir.
98
+ opts.metafile = true
99
+ opts.absWorkingDir = process.cwd()
100
+
76
101
  const esbuildStart = performance.now()
102
+ let result
77
103
  try {
78
- await build(opts)
104
+ result = await build(opts)
79
105
  } catch (err) {
80
106
  log({ tag, error: true, text: 'Failed compiling:', link: outfilePath })
81
107
  console.error(err)
@@ -83,36 +109,13 @@ export default class Scripts {
83
109
  }
84
110
  const esbuildEnd = performance.now()
85
111
 
86
- for (const entry of infilePath) {
87
- const newOutFilePath = buildScriptOutputFilePath(entry, outfilePath)
88
- const minPath = insertMinSuffix(newOutFilePath)
112
+ for (const [newOutFilePath, output] of Object.entries(result.metafile.outputs)) {
113
+ if (!output.entryPoint) continue // sourcemaps, chunks
89
114
 
90
115
  if (!options.justMinified) log({ tag, text: 'Compiled:', link: newOutFilePath, size: fileSize(newOutFilePath), time: buildTime(esbuildStart, esbuildEnd) })
91
116
  if (options.sourcemap) log({ tag, text: 'Compiled:', link: `${newOutFilePath}.map` })
92
117
 
93
- if (options.minify) {
94
- try {
95
- const terserStart = performance.now()
96
- const minifyResult = await transform(fs.readFileSync(newOutFilePath, 'utf-8'), {
97
- minify: true,
98
- loader: 'js'
99
- })
100
- const terserEnd = performance.now()
101
-
102
- if (this.banner) minifyResult.code = this.banner + '\n' + minifyResult.code
103
- fs.writeFileSync(minPath, minifyResult.code)
104
- log({ tag, text: 'Compiled:', link: minPath, size: fileSize(minPath), time: buildTime(terserStart, terserEnd) })
105
- } catch (err) {
106
- log({ tag, error: true, text: 'Failed compiling:', link: minPath })
107
- console.error(err)
108
- }
109
-
110
- if (options.justMinified) {
111
- fs.unlinkSync(newOutFilePath)
112
- }
113
- } else {
114
- if (pathExists(minPath)) fs.unlinkSync(minPath)
115
- }
118
+ await minifyToFile({ outfilePath: newOutFilePath, loader: 'js', banner: this.banner, tag, options })
116
119
  }
117
120
  }
118
121
  }