poops 1.2.2 → 1.2.3
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 +31 -0
- package/lib/markup/collections.js +4 -1
- package/lib/markup/engines/liquid.js +3 -2
- package/lib/markup/engines/nunjucks.js +6 -5
- package/lib/markup/helpers.js +47 -0
- package/lib/markups.js +2 -1
- package/package.json +1 -1
- package/poops.js +13 -1
package/README.md
CHANGED
|
@@ -68,6 +68,22 @@ or pass a custom config. This is useful when you have multiple environments:
|
|
|
68
68
|
|
|
69
69
|
`poops yourAwesomeConfig.json` or `💩 yourAwesomeConfig.json`
|
|
70
70
|
|
|
71
|
+
**CLI Options:**
|
|
72
|
+
|
|
73
|
+
| Flag | Short | Description |
|
|
74
|
+
|------|-------|-------------|
|
|
75
|
+
| `--build` | `-b` | Build the project and exit |
|
|
76
|
+
| `--config <path>` | `-c` | Specify the config file |
|
|
77
|
+
| `--port <number>` | `-p` | Specify the server port, overrides config |
|
|
78
|
+
| `--livereload-port <number>` | `-l` | Specify the livereload port, overrides config |
|
|
79
|
+
| `--base-url <path>` | `-u` | Set the base URL prefix for markup, overrides config |
|
|
80
|
+
|
|
81
|
+
The `--base-url` flag is particularly useful for CI/CD pipelines where the deploy path may differ per environment:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
poops --build --base-url /blog
|
|
85
|
+
```
|
|
86
|
+
|
|
71
87
|
If you have installed Poops locally you can run it with `npx poops` or `npx 💩` or add a script to your `package.json`:
|
|
72
88
|
|
|
73
89
|
```json
|
|
@@ -728,6 +744,21 @@ All filters are available in both engines. The only syntax difference is how arg
|
|
|
728
744
|
- Nunjucks: `{{ someCodeVariable | highlight('javascript') }}`
|
|
729
745
|
- Liquid: `{{ someCodeVariable | highlight: 'javascript' }}`
|
|
730
746
|
|
|
747
|
+
- `groupby` — groups an array of objects by a field value. Returns an array of `{ key, items }` objects. Supports an optional second argument for date part extraction (`year`, `month`, `day`). Groups preserve insertion order, so if items are sorted by date descending, groups will be too.
|
|
748
|
+
- Nunjucks: `{{ changelog.items | groupby("author") }}` or `{{ changelog.items | groupby("date", "year") }}`
|
|
749
|
+
- Liquid: `{{ changelog.items | groupby: "author" }}` or `{{ changelog.items | groupby: "date", "year" }}`
|
|
750
|
+
|
|
751
|
+
Example — group posts by year:
|
|
752
|
+
```nunjucks
|
|
753
|
+
{% set byYear = changelog.items | groupby("date", "year") %}
|
|
754
|
+
{% for group in byYear %}
|
|
755
|
+
<h2>{{ group.key }}</h2>
|
|
756
|
+
{% for post in group.items %}
|
|
757
|
+
<p>{{ post.title }}</p>
|
|
758
|
+
{% endfor %}
|
|
759
|
+
{% endfor %}
|
|
760
|
+
```
|
|
761
|
+
|
|
731
762
|
- `srcset` — returns just the srcset attribute value:
|
|
732
763
|
|
|
733
764
|
```html
|
|
@@ -3,16 +3,18 @@ import { globSync } from 'glob'
|
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
import log from '../utils/log.js'
|
|
5
5
|
import { mkDir } from '../utils/helpers.js'
|
|
6
|
-
import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, parseFrontMatter } from './helpers.js'
|
|
6
|
+
import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, parseFrontMatter, wordcount } from './helpers.js'
|
|
7
7
|
|
|
8
8
|
export function getSingleCollectionData(markupInDir, collectionName) {
|
|
9
9
|
const collectionData = []
|
|
10
10
|
globSync(path.join(process.cwd(), markupInDir, collectionName, '**/*.+(html|njk|liquid|md)'), { ignore: ['**/index.+(html|njk|liquid|md)'] }).forEach((file) => {
|
|
11
11
|
let frontMatter = {}
|
|
12
12
|
|
|
13
|
+
let content = ''
|
|
13
14
|
try {
|
|
14
15
|
const frontMatterResult = parseFrontMatter(file)
|
|
15
16
|
frontMatter = frontMatterResult.frontMatter
|
|
17
|
+
content = frontMatterResult.content
|
|
16
18
|
} catch (err) {
|
|
17
19
|
log({ tag: 'error', text: 'Failed parsing front matter:', link: file })
|
|
18
20
|
console.error(err)
|
|
@@ -23,6 +25,7 @@ export function getSingleCollectionData(markupInDir, collectionName) {
|
|
|
23
25
|
if (!frontMatter.date) {
|
|
24
26
|
frontMatter.date = fs.statSync(file).ctime.toISOString().slice(0, 16)
|
|
25
27
|
}
|
|
28
|
+
frontMatter.wordcount = wordcount(content)
|
|
26
29
|
frontMatter.fileName = path.basename(file)
|
|
27
30
|
frontMatter.filePath = path.relative(process.cwd(), file)
|
|
28
31
|
frontMatter.collection = collectionName
|
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { Liquid } from 'liquidjs'
|
|
4
4
|
import { Marked } from 'marked'
|
|
5
5
|
import { highlightRenderer, highlightCode } from '../highlight.js'
|
|
6
|
-
import { discoverImageVariants, parseFrontMatter } from '../helpers.js'
|
|
6
|
+
import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities } from '../helpers.js'
|
|
7
7
|
import { slugify } from 'book-of-spells'
|
|
8
8
|
import dayjs from 'dayjs'
|
|
9
9
|
|
|
@@ -75,6 +75,7 @@ export default class LiquidEngine {
|
|
|
75
75
|
if (variants.length === 0) return ''
|
|
76
76
|
return variants.map(v => `${v.path} ${v.width}w`).join(', ')
|
|
77
77
|
})
|
|
78
|
+
engine.registerFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
|
|
78
79
|
engine.registerFilter('highlight', (code, lang) => {
|
|
79
80
|
const highlighted = highlightCode(code, lang)
|
|
80
81
|
const langClass = lang ? ` language-${lang}` : ''
|
|
@@ -98,7 +99,7 @@ export default class LiquidEngine {
|
|
|
98
99
|
source = frontMatterResult.content
|
|
99
100
|
|
|
100
101
|
if (path.extname(templateName) === '.md') {
|
|
101
|
-
source = marked.parse(source)
|
|
102
|
+
source = decodeTemplateEntities(marked.parse(source))
|
|
102
103
|
}
|
|
103
104
|
|
|
104
105
|
const frontMatter = context.page || {}
|
|
@@ -3,7 +3,7 @@ import { globSync } from 'glob'
|
|
|
3
3
|
import { Marked } from 'marked'
|
|
4
4
|
import nunjucks from 'nunjucks'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import { discoverImageVariants, parseFrontMatter } from '../helpers.js'
|
|
6
|
+
import { discoverImageVariants, parseFrontMatter, groupby, decodeTemplateEntities } from '../helpers.js'
|
|
7
7
|
import { highlightRenderer, highlightCode } from '../highlight.js'
|
|
8
8
|
import { slugify } from 'book-of-spells'
|
|
9
9
|
import dayjs from 'dayjs'
|
|
@@ -46,7 +46,7 @@ class RelativeLoader extends nunjucks.Loader {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
if (path.extname(fullPath) === '.md') {
|
|
49
|
-
source = marked.parse(source)
|
|
49
|
+
source = decodeTemplateEntities(marked.parse(source))
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
if (frontMatter.layout) {
|
|
@@ -108,6 +108,7 @@ export default class NunjucksEngine {
|
|
|
108
108
|
if (variants.length === 0) return ''
|
|
109
109
|
return variants.map(v => `${v.path} ${v.width}w`).join(', ')
|
|
110
110
|
})
|
|
111
|
+
env.addFilter('groupby', (arr, key, datePart) => groupby(arr, key, datePart))
|
|
111
112
|
env.addFilter('highlight', (code, lang) => {
|
|
112
113
|
const highlighted = highlightCode(code, lang)
|
|
113
114
|
const langClass = lang ? ` language-${lang}` : ''
|
|
@@ -152,7 +153,7 @@ export default class NunjucksEngine {
|
|
|
152
153
|
|
|
153
154
|
// --- Nunjucks Extensions ---
|
|
154
155
|
|
|
155
|
-
class GoogleFontsExtension {
|
|
156
|
+
export class GoogleFontsExtension {
|
|
156
157
|
constructor() { this.tags = ['googleFonts'] }
|
|
157
158
|
|
|
158
159
|
parse(parser, nodes) {
|
|
@@ -195,7 +196,7 @@ class GoogleFontsExtension {
|
|
|
195
196
|
}
|
|
196
197
|
}
|
|
197
198
|
|
|
198
|
-
class ImageExtension {
|
|
199
|
+
export class ImageExtension {
|
|
199
200
|
constructor(getOutputDir) { this.tags = ['image']; this.getOutputDir = getOutputDir }
|
|
200
201
|
|
|
201
202
|
parse(parser, nodes) {
|
|
@@ -238,7 +239,7 @@ class ImageExtension {
|
|
|
238
239
|
}
|
|
239
240
|
}
|
|
240
241
|
|
|
241
|
-
class HighlightExtension {
|
|
242
|
+
export class HighlightExtension {
|
|
242
243
|
constructor() { this.tags = ['highlight'] }
|
|
243
244
|
|
|
244
245
|
parse(parser, nodes) {
|
package/lib/markup/helpers.js
CHANGED
|
@@ -58,6 +58,53 @@ export function clearFrontMatterCache(filePath) {
|
|
|
58
58
|
frontMatterCache.delete(filePath)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
export function wordcount(text) {
|
|
62
|
+
if (!text) return 0
|
|
63
|
+
// eslint-disable-next-line no-useless-escape
|
|
64
|
+
const stripped = text.replace(/<[^>]*>/g, ' ').replace(/[#*_`~\[\]()>|{}\\-]/g, ' ')
|
|
65
|
+
const words = stripped.match(/\S+/g)
|
|
66
|
+
return words ? words.length : 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// marked HTML-encodes quotes/brackets in template tags it treats as prose,
|
|
70
|
+
// breaking string args like groupby("date"). Decode entities inside {{ }} / {% %}
|
|
71
|
+
// after markdown, before the template engine parses. Decode & last so
|
|
72
|
+
// &lt; doesn't double-decode into <.
|
|
73
|
+
export function decodeTemplateEntities(html) {
|
|
74
|
+
return html.replace(/(\{\{[\s\S]*?\}\}|\{%[\s\S]*?%\})/g, (tag) =>
|
|
75
|
+
tag
|
|
76
|
+
.replace(/"/g, '"')
|
|
77
|
+
.replace(/'/g, "'")
|
|
78
|
+
.replace(/</g, '<')
|
|
79
|
+
.replace(/>/g, '>')
|
|
80
|
+
.replace(/&/g, '&')
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function groupby(arr, key, datePart) {
|
|
85
|
+
if (!Array.isArray(arr)) return []
|
|
86
|
+
|
|
87
|
+
const map = new Map()
|
|
88
|
+
for (const item of arr) {
|
|
89
|
+
let value = item[key]
|
|
90
|
+
if (datePart && value) {
|
|
91
|
+
const date = new Date(value)
|
|
92
|
+
if (!isNaN(date)) {
|
|
93
|
+
switch (datePart) {
|
|
94
|
+
case 'year': value = date.getUTCFullYear(); break
|
|
95
|
+
case 'month': value = date.getUTCMonth() + 1; break
|
|
96
|
+
case 'day': value = date.getUTCDate(); break
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const groupKey = value != null ? String(value) : ''
|
|
101
|
+
if (!map.has(groupKey)) map.set(groupKey, [])
|
|
102
|
+
map.get(groupKey).push(item)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return Array.from(map, ([key, items]) => ({ key, items }))
|
|
106
|
+
}
|
|
107
|
+
|
|
61
108
|
const FORMAT_PRIORITY = ['avif', 'webp']
|
|
62
109
|
|
|
63
110
|
export function discoverImageVariants(imagePath, outputDir) {
|
package/lib/markups.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime } from './utils/helpers.js'
|
|
2
|
-
import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache } from './markup/helpers.js'
|
|
2
|
+
import { replaceOutExtensions, getRelativePathPrefix, getPageUrl, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
|
|
3
3
|
import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
|
|
4
4
|
import { generateIndexFiles } from './markup/indexer.js'
|
|
5
5
|
import NunjucksEngine from './markup/engines/nunjucks.js'
|
|
@@ -152,6 +152,7 @@ export default class Markups {
|
|
|
152
152
|
try {
|
|
153
153
|
const frontMatterResult = parseFrontMatter(templateName)
|
|
154
154
|
context.page = frontMatterResult.frontMatter
|
|
155
|
+
context.page.wordcount = wordcount(frontMatterResult.content)
|
|
155
156
|
} catch (err) {
|
|
156
157
|
log({ tag: 'error', text: 'Failed parsing front matter:', link: templateName })
|
|
157
158
|
console.error(err)
|
package/package.json
CHANGED
package/poops.js
CHANGED
|
@@ -28,6 +28,7 @@ const cli = new Argoyle(pkg.version)
|
|
|
28
28
|
.option('config', { short: 'c', value: '<path>', description: 'Specify the config file' })
|
|
29
29
|
.option('port', { short: 'p', value: '<number>', description: 'Specify the port for the server, overrides the config file' })
|
|
30
30
|
.option('livereload-port', { short: 'l', value: '<number>', description: 'Specify the port for the livereload server, overrides the config file' })
|
|
31
|
+
.option('base-url', { short: 'u', value: '<path>', description: 'Set the base URL prefix for markup, overrides the config file' })
|
|
31
32
|
|
|
32
33
|
let flags, positionals
|
|
33
34
|
try {
|
|
@@ -41,6 +42,7 @@ const build = flags.build
|
|
|
41
42
|
const defaultConfigPath = flags.config || positionals[0] || 'poops.json'
|
|
42
43
|
const overridePort = flags.port
|
|
43
44
|
const overrideLivereloadPort = flags['livereload-port']
|
|
45
|
+
const overrideBaseURL = flags['base-url']
|
|
44
46
|
|
|
45
47
|
let configPath = path.join(cwd, defaultConfigPath)
|
|
46
48
|
if (!pathExists(configPath)) configPath = path.join(cwd, '💩.json') // TODO: Ok dude, I know it's late, but you can do better than this.
|
|
@@ -82,7 +84,13 @@ function setupWatchers(config, modules) {
|
|
|
82
84
|
|
|
83
85
|
// TODO: think about watching the updates of the config file itself, we can reload the config and recompile everything.
|
|
84
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.
|
|
85
|
-
|
|
87
|
+
// awaitWriteFinish: wait for saves to finish writing before recompiling, so a
|
|
88
|
+
// 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) => {
|
|
86
94
|
if (/(\.m?jsx?|\.tsx?)$/i.test(file)) {
|
|
87
95
|
modules.scripts.compile().catch(err => console.error(err))
|
|
88
96
|
|
|
@@ -179,6 +187,10 @@ if (!config.reactor && config.ssg) {
|
|
|
179
187
|
config.reactor = config.ssg
|
|
180
188
|
}
|
|
181
189
|
|
|
190
|
+
if (overrideBaseURL && config.markup) {
|
|
191
|
+
config.markup.baseURL = overrideBaseURL
|
|
192
|
+
}
|
|
193
|
+
|
|
182
194
|
async function getAvailablePort(port, max) {
|
|
183
195
|
while (port < max) {
|
|
184
196
|
const status = await portscanner.checkPortStatus(port, 'localhost')
|