poops 1.9.5 → 1.9.7

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
@@ -217,7 +217,7 @@ You can freely remove the properties that you don't need. For example, if you do
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
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
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:**
@@ -351,7 +351,7 @@ Poops does not need `react` or `react-dom` as its own dependency — they are re
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
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
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:**
@@ -1093,6 +1093,18 @@ All filters are available in both engines. The only syntax difference is how arg
1093
1093
  ---
1094
1094
  ```
1095
1095
 
1096
+ The same `jsonld` object works in your `site` data, as a site-wide default — useful when every page on the site is one type. A docs site is `TechArticle`, not `WebPage`:
1097
+
1098
+ ```json
1099
+ "markup": {
1100
+ "site": {
1101
+ "jsonld": { "@type": "TechArticle" }
1102
+ }
1103
+ }
1104
+ ```
1105
+
1106
+ Precedence is defaults → `site.jsonld` → `page.jsonld`, so a single page can still opt out (a `FAQPage` inside a `TechArticle` site). A site-wide `@type` also overrides the auto-detected `BlogPosting` on dated pages, so on a site mixing docs and a blog set the type per page instead. Both merge into the page's own block only — the auto-emitted `WebSite` and `BreadcrumbList` blocks are untouched.
1107
+
1096
1108
  poops auto-picks `BlogPosting` (page has a `date`) or `WebPage`. Override `@type` with the `jsonld` object for any schema.org type — common ones search/generative engines act on: `Article`, `NewsArticle`, `HowTo`, `FAQPage`, `QAPage`, `Product`, `Recipe`, `Event`, `Course`, `VideoObject`, `SoftwareApplication`, `Organization`, `Person`, `BreadcrumbList`, `WebSite`. Full list at [schema.org/docs/full](https://schema.org/docs/full.html); validate with the [Rich Results Test](https://search.google.com/test/rich-results). A per-`@type` table with the notable fields is in the [Templating docs](example/src/markup/docs/quick-start/templating-html.md).
1097
1109
 
1098
1110
  - `breadcrumb` — generates a visible breadcrumb `<nav class="breadcrumb"><ol>…</ol></nav>` trail for the page body (blog posts, nested pages), from the same URL-depth data the `jsonld` `BreadcrumbList` uses: the site root, each ancestor folder (humanized, e.g. `docs/static-site` → _Static Site_), then the current page as `aria-current` text. Pass `relativePathPrefix` so links resolve against the current output location (localhost in dev, your deployed subpath in prod) — not the absolute domain.
@@ -532,10 +532,12 @@ export function buildBreadcrumb(page, site = {}, prefix = '') {
532
532
  // gains a `logo` ImageObject when `site.logo` is set — Google Article rich
533
533
  // results require it. On the homepage (page has no `url`) a second `WebSite`
534
534
  // block is emitted, declaring the site name for search results.
535
- // Anything in `page.jsonld` (an object) is merged over the generated defaults,
536
- // so a page can add or replace any field including @type as a full escape
537
- // hatch. Values come from front matter, so the JSON is escaped for a <script>
538
- // context to prevent a `</script>` break-out (XSS).
535
+ // `site.jsonld` and `page.jsonld` (objects) are merged over the generated
536
+ // defaults site-wide first, then the page so a site can set a default
537
+ // @type (a docs site is TechArticle, not WebPage) and a page can still add or
538
+ // replace any field as a full escape hatch. Values come from front matter, so
539
+ // the JSON is escaped for a <script> context to prevent a `</script>`
540
+ // break-out (XSS).
539
541
  export function buildJsonLd(page, site = {}) {
540
542
  page = page || {}
541
543
  site = site || {}
@@ -577,8 +579,10 @@ export function buildJsonLd(page, site = {}) {
577
579
  if (page.wordcount) data.wordCount = page.wordcount
578
580
  }
579
581
 
580
- // page.jsonld extends/overrides. Shallow merge nested schema is rare and
581
- // the escape hatch is meant for whole-key replacement.
582
+ // site.jsonld then page.jsonld extend/overridethe page wins. Shallow
583
+ // merge — nested schema is rare and the escape hatch is meant for whole-key
584
+ // replacement.
585
+ if (site.jsonld && typeof site.jsonld === 'object') Object.assign(data, site.jsonld)
582
586
  if (page.jsonld && typeof page.jsonld === 'object') Object.assign(data, page.jsonld)
583
587
 
584
588
  const blocks = [jsonLdScript(data)]
package/lib/scripts.js CHANGED
@@ -3,15 +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,
8
9
  isIndexEntry,
9
10
  globBase,
10
11
  indexEntryOut,
12
+ entryBase,
13
+ hasOutTemplate,
14
+ outTemplateBase,
15
+ fillOutTemplateEntry,
11
16
  fillBannerTemplate,
12
17
  buildTime,
13
18
  fileSize
14
19
  } from './utils/helpers.js'
20
+ import path from 'node:path'
15
21
  import minifyToFile from './utils/minify.js'
16
22
  import log from './utils/log.js'
17
23
 
@@ -30,6 +36,11 @@ export default class Scripts {
30
36
  // `in` may be an array of entry points — pathExists on an array throws
31
37
  const configured = Array.isArray(scriptEntry.in) ? scriptEntry.in : [scriptEntry.in]
32
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
33
44
  let missing = false
34
45
  for (const entry of configured) {
35
46
  if (hasMagic(entry, { magicalBraces: true })) {
@@ -44,19 +55,31 @@ export default class Scripts {
44
55
  // `index.js` or nests a level deep under esbuild's outbase. Literal
45
56
  // entries keep their basename — see `isIndexEntry`.
46
57
  const base = globBase(entry)
47
- entryPoints.push(...matches.map(match => (
48
- isIndexEntry(match) ? { in: match, out: indexEntryOut(match, base) } : match
49
- )))
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
+ }))
50
62
  } else if (!pathExists(entry)) {
51
63
  log({ tag: 'script', error: true, text: 'Entry does not exist:', link: entry })
52
64
  missing = true
65
+ } else if (templated) {
66
+ entryPoints.push({ in: entry, out: fillOutTemplateEntry(scriptEntry.out, entry, entryBase(entry), outBase) })
53
67
  } else {
54
68
  entryPoints.push(entry)
55
69
  }
56
70
  }
57
71
  if (missing) continue
58
- mkPath(scriptEntry.out)
59
- 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)
60
83
  }
61
84
  }
62
85
 
package/lib/styles.js CHANGED
@@ -8,6 +8,9 @@ import {
8
8
  isIndexEntry,
9
9
  globBase,
10
10
  indexEntryOut,
11
+ entryBase,
12
+ hasOutTemplate,
13
+ fillOutTemplate,
11
14
  fillBannerTemplate,
12
15
  buildTime,
13
16
  fileSize
@@ -23,10 +26,15 @@ export default class Styles {
23
26
  constructor(config) {
24
27
  this.config = config
25
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 = []
26
33
  }
27
34
 
28
35
  async compile() {
29
36
  if (!this.config.styles) return
37
+ this.outputs = []
30
38
  this.config.styles = Array.isArray(this.config.styles) ? this.config.styles : [this.config.styles]
31
39
  for (const styleEntry of this.config.styles) {
32
40
  if (!styleEntry.in || !styleEntry.out) continue
@@ -34,6 +42,8 @@ export default class Styles {
34
42
  const configured = Array.isArray(styleEntry.in) ? styleEntry.in : [styleEntry.in]
35
43
  const entryPoints = []
36
44
  const indexEntries = new Map()
45
+ const bases = new Map()
46
+ const templated = hasOutTemplate(styleEntry.out)
37
47
  let missing = false
38
48
  for (const entry of configured) {
39
49
  if (hasMagic(entry, { magicalBraces: true })) {
@@ -50,25 +60,29 @@ export default class Styles {
50
60
  const base = globBase(entry)
51
61
  for (const match of matches) {
52
62
  if (isIndexEntry(match)) indexEntries.set(match, indexEntryOut(match, base))
63
+ bases.set(match, base)
53
64
  }
54
65
  entryPoints.push(...matches)
55
66
  } else if (!pathExists(entry)) {
56
67
  log({ tag: 'style', error: true, text: 'Entry does not exist:', link: entry })
57
68
  missing = true
58
69
  } else {
70
+ bases.set(entry, entryBase(entry))
59
71
  entryPoints.push(entry)
60
72
  }
61
73
  }
62
74
  if (missing) continue
63
- 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)) {
64
78
  log({ tag: 'error', text: 'Cannot output multiple style files to a single file. Please specify an output directory path instead.' })
65
79
  process.exit(1)
66
80
  }
67
81
  for (const entryPoint of entryPoints) {
68
82
  // Resolve the index rename here, where `out` is still known to be a dir
69
- const out = indexEntries.has(entryPoint) && !pathForFile(styleEntry.out)
70
- ? path.join(styleEntry.out, `${indexEntries.get(entryPoint)}.css`)
71
- : styleEntry.out
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`)
72
86
  await this.compileEntry(entryPoint, out, styleEntry.options)
73
87
  }
74
88
  }
@@ -113,6 +127,7 @@ export default class Styles {
113
127
  const mapsrc = options.sourcemap ? `\n/*# sourceMappingURL=${path.basename(outfilePath)}.map */` : ''
114
128
  if (this.banner) compiledSass.css = this.banner + '\n' + compiledSass.css
115
129
  fs.writeFileSync(outfilePath, compiledSass.css + mapsrc)
130
+ this.outputs.push(outfilePath) // after the write — a failed compile has nothing to reload
116
131
  const stylesEnd = performance.now()
117
132
  if (!options.justMinified) log({ tag: 'style', text: 'Compiled:', link: outfilePath, size: fileSize(outfilePath), time: buildTime(stylesStart, stylesEnd) })
118
133
 
@@ -62,6 +62,45 @@ export function indexEntryOut(inputPath, base) {
62
62
  return rel || path.posix.basename(dir)
63
63
  }
64
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
+
65
104
  export function buildStyleOutputFilePath(inputPath, outputPath) {
66
105
  if (pathForFile(outputPath)) return outputPath
67
106
  const { name } = path.parse(inputPath)
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.5",
4
+ "version": "1.9.7",
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