poops 1.9.4 → 1.9.6

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 CHANGED
@@ -216,8 +216,8 @@ You can freely remove the properties that you don't need. For example, if you do
216
216
 
217
217
  Scripts are bundled with [esbuild](https://esbuild.github.io/). Supports `.js`, `.ts`, `.jsx`, and `.tsx` files out of the box — including React and other JSX frameworks. You can specify multiple scripts to bundle. Each script has the following properties:
218
218
 
219
- - `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/js/*.js"`). Globs must use `/` separators (even on Windows)
220
- - `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — entry points from different directories nest their output under the common ancestor (esbuild's `outbase`)
219
+ - `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/js/*.js"`, `"src/elements/*/index.{js,mjs,cjs,jsx,ts,tsx}"`). Globs must use `/` separators (even on Windows)
220
+ - `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — entry points from different directories nest their output under the common ancestor (esbuild's `outbase`). A **glob-matched** `index.*` is named after its directory instead, placed relative to the glob's static prefix: `"src/elements/*/index.js"` gives `<out>/accordion.js`, while `"src/*/accordion/index.js"` keeps the differing segment as `<out>/blocks/accordion.js`. A literal `in: "src/index.js"` keeps its own basename. To name outputs yourself, `out` can be a **template**: `{{dir}}` is the input's directory relative to the glob's static prefix, `{{name}}` its basename without extension. `"src/elements/*/widget.ts"` with `out: "dist/js/{{dir}}-{{name}}.js"` gives `dist/js/accordion-widget.js` and `dist/js/tabs-widget.js` — one bundle per match, named by you instead of by the common ancestor. The template's extension is honoured too, so `out: "dist/esm/{{dir}}.mjs"` writes `.mjs` files
221
221
  - `options` - the options for the bundler. You can apply most of the esbuild options that are not in conflict with Poops. See [esbuild's options](https://esbuild.github.io/api/#build-api) for more info.
222
222
 
223
223
  **Options:**
@@ -350,8 +350,8 @@ Poops does not need `react` or `react-dom` as its own dependency — they are re
350
350
 
351
351
  Styles are bundled with [Dart Sass](https://sass-lang.com/dart-sass). You can specify multiple styles to bundle. Each style has the following properties:
352
352
 
353
- - `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/scss/*.scss"`). Globs must use `/` separators (even on Windows) and skip Sass partials (`_*.scss`). Each matched file is compiled separately
354
- - `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — each input compiles to `<out>/<basename>.css`, so inputs sharing a basename (e.g. `a/main.scss` and `b/main.scss`) will overwrite each other
353
+ - `in` - the input path, can be a file path, an array of file paths, or a glob pattern (e.g. `"src/scss/*.scss"`, `"src/elements/*/index.{scss,sass,css}"`). Globs must use `/` separators (even on Windows) and skip Sass partials (`_*.scss`). Each matched file is compiled separately
354
+ - `out` - the output path, can be a directory or a file path. With multiple inputs it must be a directory — each input compiles to `<out>/<basename>.css`, so inputs sharing a basename (e.g. `a/main.scss` and `b/main.scss`) will overwrite each other. A **glob-matched** `index.*` is named after its directory instead, placed relative to the glob's static prefix: `"src/elements/*/index.scss"` gives `<out>/accordion.css`, while `"src/*/accordion/index.scss"` keeps the differing segment as `<out>/blocks/accordion.css`. A literal `in: "src/scss/index.scss"` keeps its own basename. To name outputs yourself, `out` can be a **template**: `{{dir}}` is the input's directory relative to the glob's static prefix, `{{name}}` its basename without extension. `"src/elements/*/theme.scss"` with `out: "dist/{{dir}}-theme.css"` gives `dist/accordion-theme.css` and `dist/tabs-theme.css` — one output per match, instead of every `theme.scss` overwriting the same `theme.css`
355
355
  - `options` - the options for the bundler.
356
356
 
357
357
  **Options:**
package/lib/copy.js CHANGED
@@ -34,7 +34,7 @@ export default class Copy {
34
34
 
35
35
  for (const inEntry of inEntries) {
36
36
  const hasGlobMagic = typeof hasMagic === 'function'
37
- ? hasMagic(inEntry)
37
+ ? hasMagic(inEntry, { magicalBraces: true })
38
38
  : ['*', '?', '[', ']', '{', '}', '(', ')', '!'].some((ch) => inEntry.includes(ch))
39
39
  let matches = []
40
40
  try {
package/lib/scripts.js CHANGED
@@ -3,12 +3,21 @@ import { globSync, hasMagic } from 'glob'
3
3
  import { deepMerge } from 'book-of-spells'
4
4
  import {
5
5
  pathExists,
6
+ mkDir,
6
7
  mkPath,
7
8
  pathForFile,
9
+ isIndexEntry,
10
+ globBase,
11
+ indexEntryOut,
12
+ entryBase,
13
+ hasOutTemplate,
14
+ outTemplateBase,
15
+ fillOutTemplateEntry,
8
16
  fillBannerTemplate,
9
17
  buildTime,
10
18
  fileSize
11
19
  } from './utils/helpers.js'
20
+ import path from 'node:path'
12
21
  import minifyToFile from './utils/minify.js'
13
22
  import log from './utils/log.js'
14
23
 
@@ -27,26 +36,50 @@ export default class Scripts {
27
36
  // `in` may be an array of entry points — pathExists on an array throws
28
37
  const configured = Array.isArray(scriptEntry.in) ? scriptEntry.in : [scriptEntry.in]
29
38
  const entryPoints = []
39
+ // A templated `out` names one output per entry point. esbuild already
40
+ // takes a per-entry `out` relative to `outdir` — the same door the
41
+ // `index.*` rename goes through — so the template resolves into that.
42
+ const templated = hasOutTemplate(scriptEntry.out)
43
+ const outBase = templated ? outTemplateBase(scriptEntry.out) : null
30
44
  let missing = false
31
45
  for (const entry of configured) {
32
- if (hasMagic(entry)) {
46
+ if (hasMagic(entry, { magicalBraces: true })) {
33
47
  // Globs must use `/` even on Windows; sort for deterministic build order
34
48
  const matches = globSync(entry, { posix: true }).sort()
35
49
  if (!matches.length) {
36
50
  log({ tag: 'script', error: true, text: 'Entry does not exist:', link: entry })
37
51
  missing = true
38
52
  }
39
- entryPoints.push(...matches)
53
+ // A glob-matched `index.js` is named after its directory, relative to
54
+ // the glob's static prefix — otherwise every component collides on
55
+ // `index.js` or nests a level deep under esbuild's outbase. Literal
56
+ // entries keep their basename — see `isIndexEntry`.
57
+ const base = globBase(entry)
58
+ entryPoints.push(...matches.map(match => {
59
+ if (templated) return { in: match, out: fillOutTemplateEntry(scriptEntry.out, match, base, outBase) }
60
+ return isIndexEntry(match) ? { in: match, out: indexEntryOut(match, base) } : match
61
+ }))
40
62
  } else if (!pathExists(entry)) {
41
63
  log({ tag: 'script', error: true, text: 'Entry does not exist:', link: entry })
42
64
  missing = true
65
+ } else if (templated) {
66
+ entryPoints.push({ in: entry, out: fillOutTemplateEntry(scriptEntry.out, entry, entryBase(entry), outBase) })
43
67
  } else {
44
68
  entryPoints.push(entry)
45
69
  }
46
70
  }
47
71
  if (missing) continue
48
- mkPath(scriptEntry.out)
49
- await this.compileEntry(entryPoints, scriptEntry.out, scriptEntry.options)
72
+ let options = scriptEntry.options
73
+ if (templated) {
74
+ mkDir(outBase)
75
+ // esbuild appends its own extension, so a template asking for one other
76
+ // than `.js` has to be honoured through `outExtension` or it's ignored
77
+ const ext = path.posix.extname(scriptEntry.out)
78
+ if (ext && ext !== '.js') options = { outExtension: { '.js': ext }, ...options }
79
+ } else {
80
+ mkPath(scriptEntry.out)
81
+ }
82
+ await this.compileEntry(entryPoints, templated ? outBase : scriptEntry.out, options)
50
83
  }
51
84
  }
52
85
 
@@ -78,6 +111,9 @@ export default class Scripts {
78
111
  log({ tag: 'error', text: 'Cannot output multiple script files to a single file. Please specify an output directory path instead.' })
79
112
  process.exit(1)
80
113
  }
114
+ // An explicit `outfile` names the output itself, so drop any `{ in, out }`
115
+ // wrapper the glob resolver added — esbuild rejects the pair.
116
+ opts.entryPoints = infilePath.map(entry => entry.in || entry)
81
117
  opts.outfile = outfilePath
82
118
  }
83
119
 
package/lib/styles.js CHANGED
@@ -5,6 +5,12 @@ import {
5
5
  mkPath,
6
6
  pathForFile,
7
7
  buildStyleOutputFilePath,
8
+ isIndexEntry,
9
+ globBase,
10
+ indexEntryOut,
11
+ entryBase,
12
+ hasOutTemplate,
13
+ fillOutTemplate,
8
14
  fillBannerTemplate,
9
15
  buildTime,
10
16
  fileSize
@@ -20,19 +26,27 @@ export default class Styles {
20
26
  constructor(config) {
21
27
  this.config = config
22
28
  this.banner = config.banner ? fillBannerTemplate(config.banner) : null
29
+ // The css files the last compile wrote. Globs and templated `out` name a
30
+ // file per match, so the config alone can't say what landed where — the
31
+ // livereload chain reads this to hot-swap exactly the stylesheets rebuilt.
32
+ this.outputs = []
23
33
  }
24
34
 
25
35
  async compile() {
26
36
  if (!this.config.styles) return
37
+ this.outputs = []
27
38
  this.config.styles = Array.isArray(this.config.styles) ? this.config.styles : [this.config.styles]
28
39
  for (const styleEntry of this.config.styles) {
29
40
  if (!styleEntry.in || !styleEntry.out) continue
30
41
  // `in` may be an array of entry points and/or globs — same resolution as scripts
31
42
  const configured = Array.isArray(styleEntry.in) ? styleEntry.in : [styleEntry.in]
32
43
  const entryPoints = []
44
+ const indexEntries = new Map()
45
+ const bases = new Map()
46
+ const templated = hasOutTemplate(styleEntry.out)
33
47
  let missing = false
34
48
  for (const entry of configured) {
35
- if (hasMagic(entry)) {
49
+ if (hasMagic(entry, { magicalBraces: true })) {
36
50
  // Globs must use `/` even on Windows; sort for deterministic build order.
37
51
  // Skip sass partials (_*.scss) — they are imports, not entry points.
38
52
  const matches = globSync(entry, { posix: true }).filter(match => !path.basename(match).startsWith('_')).sort()
@@ -40,21 +54,36 @@ export default class Styles {
40
54
  log({ tag: 'style', error: true, text: 'Entry does not exist:', link: entry })
41
55
  missing = true
42
56
  }
57
+ // A glob-matched `index.scss` is named after its directory, relative
58
+ // to the glob's static prefix — otherwise every component collides on
59
+ // `index.css`. Literal entries keep their basename — see `isIndexEntry`.
60
+ const base = globBase(entry)
61
+ for (const match of matches) {
62
+ if (isIndexEntry(match)) indexEntries.set(match, indexEntryOut(match, base))
63
+ bases.set(match, base)
64
+ }
43
65
  entryPoints.push(...matches)
44
66
  } else if (!pathExists(entry)) {
45
67
  log({ tag: 'style', error: true, text: 'Entry does not exist:', link: entry })
46
68
  missing = true
47
69
  } else {
70
+ bases.set(entry, entryBase(entry))
48
71
  entryPoints.push(entry)
49
72
  }
50
73
  }
51
74
  if (missing) continue
52
- if (entryPoints.length > 1 && pathForFile(styleEntry.out)) {
75
+ // A templated `out` resolves to a different file per entry point, so the
76
+ // single-file guard doesn't apply to it
77
+ if (!templated && entryPoints.length > 1 && pathForFile(styleEntry.out)) {
53
78
  log({ tag: 'error', text: 'Cannot output multiple style files to a single file. Please specify an output directory path instead.' })
54
79
  process.exit(1)
55
80
  }
56
81
  for (const entryPoint of entryPoints) {
57
- await this.compileEntry(entryPoint, styleEntry.out, styleEntry.options)
82
+ // Resolve the index rename here, where `out` is still known to be a dir
83
+ let out = styleEntry.out
84
+ if (templated) out = fillOutTemplate(out, entryPoint, bases.get(entryPoint))
85
+ else if (indexEntries.has(entryPoint) && !pathForFile(out)) out = path.join(out, `${indexEntries.get(entryPoint)}.css`)
86
+ await this.compileEntry(entryPoint, out, styleEntry.options)
58
87
  }
59
88
  }
60
89
  }
@@ -98,6 +127,7 @@ export default class Styles {
98
127
  const mapsrc = options.sourcemap ? `\n/*# sourceMappingURL=${path.basename(outfilePath)}.map */` : ''
99
128
  if (this.banner) compiledSass.css = this.banner + '\n' + compiledSass.css
100
129
  fs.writeFileSync(outfilePath, compiledSass.css + mapsrc)
130
+ this.outputs.push(outfilePath) // after the write — a failed compile has nothing to reload
101
131
  const stylesEnd = performance.now()
102
132
  if (!options.justMinified) log({ tag: 'style', text: 'Compiled:', link: outfilePath, size: fileSize(outfilePath), time: buildTime(stylesStart, stylesEnd) })
103
133
 
@@ -34,10 +34,77 @@ export function insertMinSuffix(filePath) {
34
34
  return path.join(path.dirname(filePath), `${name}.min${ext}`)
35
35
  }
36
36
 
37
+ // `index` is a directory entry point — name the output after the directory, so
38
+ // `src/elements/accordion/index.scss` outputs `accordion.css`. Only applied to
39
+ // glob matches: a literal `in: 'src/index.js'` keeps its own basename, since
40
+ // renaming it to `src.js` would silently move an explicitly named output.
41
+ export function isIndexEntry(inputPath) {
42
+ const { dir, name } = path.parse(inputPath)
43
+ return name === 'index' && !!dir && dir !== '.' && dir !== '..'
44
+ }
45
+
46
+ // The static prefix of a glob — everything before its first magic segment.
47
+ // `src/elements/*/index.ts` → `src/elements`, `src/*/accordion/index.ts` → `src`.
48
+ // Derived from the pattern alone, so it doesn't shift with what happened to match.
49
+ export function globBase(pattern) {
50
+ const parts = toPosix(pattern).split('/')
51
+ const magicAt = parts.findIndex(part => hasMagic(part, { magicalBraces: true }))
52
+ return parts.slice(0, magicAt === -1 ? -1 : magicAt).join('/')
53
+ }
54
+
55
+ // Extension-less output path for a glob-matched `index.*`: the directory name,
56
+ // keeping whatever nesting the glob's static prefix doesn't already account for.
57
+ // A glob spanning several groups (`src/*/accordion/index.ts`) therefore stays
58
+ // apart as `blocks/accordion` and `elements/accordion` instead of colliding.
59
+ export function indexEntryOut(inputPath, base) {
60
+ const dir = path.posix.dirname(toPosix(inputPath))
61
+ const rel = base ? path.posix.relative(base, dir) : dir
62
+ return rel || path.posix.basename(dir)
63
+ }
64
+
65
+ // The `index.*` rename only saves entry points named `index` — every other
66
+ // glob match still collapses onto its own basename, so `src/elements/*/theme.scss`
67
+ // writes `theme.css` once per element and the last one wins. An `out` carrying
68
+ // `{{dir}}`/`{{name}}` names one output per match instead.
69
+ export function hasOutTemplate(outputPath) {
70
+ return /{{\s*(dir|name)\s*}}/.test(outputPath)
71
+ }
72
+
73
+ // `{{dir}}` is the match's directory relative to the glob's static prefix (the
74
+ // same name `index.*` entries get), `{{name}}` its extension-less basename.
75
+ export function fillOutTemplate(template, inputPath, base) {
76
+ const { name } = path.parse(inputPath)
77
+ return template
78
+ .replace(/{{\s*dir\s*}}/g, indexEntryOut(inputPath, base))
79
+ .replace(/{{\s*name\s*}}/g, name)
80
+ }
81
+
82
+ // What an entry's outputs are named relative to: a glob's static prefix, or a
83
+ // literal entry's own directory (so `{{dir}}` is that directory's name).
84
+ export function entryBase(entry) {
85
+ return hasMagic(entry, { magicalBraces: true }) ? globBase(entry) : path.posix.dirname(toPosix(entry))
86
+ }
87
+
88
+ // The fixed directory a templated `out` writes into — everything before its
89
+ // first token. Only the tail varies per entry point, so this is the one part
90
+ // esbuild (and the watcher's output zones) can be handed up front.
91
+ export function outTemplateBase(outputPath) {
92
+ return path.posix.dirname(toPosix(outputPath).replace(/\{\{.*$/, ''))
93
+ }
94
+
95
+ // esbuild names an entry point's output itself, from an `out` that is relative
96
+ // to `outdir` and carries no extension. Resolve the `out` template into that
97
+ // shape so a templated scripts `out` goes through the same machinery the
98
+ // `index.*` rename already uses.
99
+ export function fillOutTemplateEntry(template, inputPath, base, outBase) {
100
+ const filled = toPosix(fillOutTemplate(template, inputPath, base))
101
+ return path.posix.relative(outBase, filled).replace(/\.[^./]*$/, '')
102
+ }
103
+
37
104
  export function buildStyleOutputFilePath(inputPath, outputPath) {
38
105
  if (pathForFile(outputPath)) return outputPath
39
106
  const { name } = path.parse(inputPath)
40
- return path.join(path.join(outputPath, `${name}.css`))
107
+ return path.join(outputPath, `${name}.css`)
41
108
  }
42
109
 
43
110
  export function fillBannerTemplate(template, packagesPath) {
@@ -185,7 +252,7 @@ export function pathContainsPathSegment(filePath, segment) {
185
252
  // Watcher paths arrive with native separators, config segments with `/`
186
253
  filePath = toPosix(filePath)
187
254
  segment = stripDirNavSegments(segment)
188
- if (hasMagic(segment)) {
255
+ if (hasMagic(segment, { magicalBraces: true })) {
189
256
  segment = convertGlobToRegex(segment)
190
257
  if (!segment) return false
191
258
  return segment.test(filePath)
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.4",
4
+ "version": "1.9.6",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
package/poops.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import chokidar from 'chokidar'
4
4
  import Copy from './lib/copy.js'
5
5
  import runExec, { validateExec } from './lib/exec.js'
6
- import { pathExists, doesFileBelongToPath, pathContainsPathSegment, deriveWatchDirs, toPosix } from './lib/utils/helpers.js'
6
+ import { pathExists, doesFileBelongToPath, pathContainsPathSegment, deriveWatchDirs, toPosix, hasOutTemplate, outTemplateBase } from './lib/utils/helpers.js'
7
7
  import http from 'node:http'
8
8
  import net from 'node:net'
9
9
  import os from 'node:os'
@@ -89,17 +89,15 @@ function reload(file) {
89
89
  }, 500)
90
90
  }
91
91
 
92
- // The css output paths of the styles entries — what the styles chain reports
93
- // to reload() so style edits hot-swap. A directory `out` maps to the entry
94
- // point's basename, mirroring how the styles compiler names its output file.
92
+ // The css files the last styles compile actually wrote — what the styles chain
93
+ // reports to reload() so style edits hot-swap. Read off the compiler rather
94
+ // than guessed from the config: a glob or a templated `out` names one output
95
+ // per match, and a guessed path the browser has no stylesheet for silently
96
+ // downgrades the hot-swap to a full page reload.
95
97
  // toPosix: the livereload client matches these against URL paths, so Windows
96
98
  // backslashes would silently break the CSS hot-swap.
97
- function styleOutputs(config) {
98
- return [config.styles].flat()
99
- .filter((entry) => entry && entry.in && entry.out)
100
- .map((entry) => toPosix(path.extname(entry.out)
101
- ? entry.out
102
- : path.join(entry.out, path.basename(entry.in).replace(/\.(sass|scss)$/i, '.css'))))
99
+ function styleOutputs(styles) {
100
+ return styles.outputs.map(toPosix)
103
101
  }
104
102
 
105
103
  // Per-stage shell hooks (config.exec). Runs after a stage compiles in both
@@ -135,7 +133,11 @@ function setupWatchers(config, modules) {
135
133
  // the watch list, or every compile retriggers itself.
136
134
  const outputZones = [config.scripts, config.styles].flat()
137
135
  .filter((entry) => entry && entry.out)
138
- .map((entry) => (path.extname(entry.out) ? path.dirname(entry.out) : entry.out))
136
+ // A templated `out` is only static up to its first token, so zone on that
137
+ // prefix — `dist/{{dir}}/theme.css` writes into `dist`, not `dist/{{dir}}`
138
+ .map((entry) => (hasOutTemplate(entry.out)
139
+ ? outTemplateBase(entry.out)
140
+ : (path.extname(entry.out) ? path.dirname(entry.out) : entry.out)))
139
141
  .filter((zone) => zone && zone !== '.')
140
142
  const isBuildOutput = (file) => outputZones.some((zone) => pathContainsPathSegment(file, zone))
141
143
 
@@ -161,7 +163,7 @@ function setupWatchers(config, modules) {
161
163
 
162
164
  const recompileStyles = coalesce(() => {
163
165
  modules.styles.compile().then(() => modules.postcss.compile())
164
- .then(() => { hook('styles'); styleOutputs(config).forEach((out) => reload(out)) })
166
+ .then(() => { hook('styles'); styleOutputs(modules.styles).forEach((out) => reload(out)) })
165
167
  .catch(err => console.error(err))
166
168
  })
167
169