poops 1.5.1 → 1.5.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.
@@ -262,7 +262,8 @@ export function generateCollectionPaginationPages(collectionData, markupInDir, m
262
262
  const compilePromise = compileEntryFn(file, context).then(({ result, skipped }) => {
263
263
  if (skipped) return
264
264
  mkDir(markupOutDirFull)
265
- fs.writeFileSync(markupOut, result)
265
+ // async write so I/O overlaps rendering of the other pages
266
+ return fs.promises.writeFile(markupOut, result)
266
267
  })
267
268
  compilePromises.push(compilePromise)
268
269
  }
@@ -2,12 +2,22 @@ import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { Liquid } from 'liquidjs'
4
4
  import { highlightCode } from '../highlight.js'
5
- import { marked, renderMarkdown } from '../renderer.js'
5
+ import { marked, renderMarkdownCached } from '../renderer.js'
6
6
  import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildPaginationTag, renderToc } from '../helpers.js'
7
7
  import { getImageExif, listImages } from '../image-cache.js'
8
8
  import { slugify } from 'book-of-spells'
9
9
  import dayjs from 'dayjs'
10
10
 
11
+ // liquidjs parse-cache keys: "<lookupType>:<name>" (root/layouts/partials)
12
+ // for named partials, "<includerPath>,<name>" for relative references — see
13
+ // liquidjs Parser._parseFileCached. The name part is what the template wrote.
14
+ function depNameFromKey(key) {
15
+ const m = /^(root|layouts|partials):/.exec(key)
16
+ if (m) return key.slice(m[0].length)
17
+ const comma = key.indexOf(',')
18
+ return comma === -1 ? key : key.slice(comma + 1)
19
+ }
20
+
11
21
  export default class LiquidEngine {
12
22
  constructor(templatesDir, includePaths) {
13
23
  const roots = [templatesDir]
@@ -24,14 +34,32 @@ export default class LiquidEngine {
24
34
  }
25
35
  } catch { /* ignore */ }
26
36
 
37
+ // Dependency index for incremental rebuilds, fed by the recording parse
38
+ // cache below. Persists across compiles; each page render overwrites its
39
+ // own entry.
40
+ this.deps = new Map()
41
+ this._currentDeps = null
42
+
43
+ // Parsed-template cache: without it every {% render %}/{% include %}
44
+ // re-resolves (realpath per root) and re-parses the partial on every
45
+ // page — dominates build time on partial-heavy sites. Cleared per
46
+ // compile (clearCache) so watch-mode edits are picked up.
47
+ // Recording: liquidjs consults this cache for every partial use, hits
48
+ // included, so read+write touches are a complete per-page partial trace
49
+ // (the loader/fs only fire on misses).
50
+ const store = new Map()
51
+ const touch = (key) => { if (this._currentDeps) this._currentDeps.add(depNameFromKey(key)) }
52
+ const parseCache = {
53
+ read: (key) => { const value = store.get(key); if (value !== undefined) touch(key); return value },
54
+ write: (key, value) => { store.set(key, value); touch(key) },
55
+ remove: (key) => store.delete(key),
56
+ clear: () => store.clear()
57
+ }
58
+
27
59
  this.engine = new Liquid({
28
60
  root: roots,
29
61
  extname: '.liquid',
30
- // Parsed-template cache: without it every {% render %}/{% include %}
31
- // re-resolves (realpath per root) and re-parses the partial on every
32
- // page — dominates build time on partial-heavy sites. Cleared per
33
- // compile (clearCache) so watch-mode edits are picked up.
34
- cache: true,
62
+ cache: parseCache,
35
63
  dynamicPartials: true,
36
64
  strictFilters: false,
37
65
  strictVariables: false,
@@ -114,13 +142,32 @@ export default class LiquidEngine {
114
142
  if (this.engine.options.cache) this.engine.options.cache.clear()
115
143
  }
116
144
 
145
+ // Incremental rebuilds: pages whose last render touched this file. Dep
146
+ // names are partial names as written in templates ('sections/foo', no
147
+ // extension), so match by basename — a same-named file elsewhere
148
+ // over-rebuilds (safe), never under-rebuilds.
149
+ pagesDependingOn(file) {
150
+ const full = path.resolve(process.cwd(), file)
151
+ const base = path.basename(full, path.extname(full))
152
+ const pages = []
153
+ for (const [page, deps] of this.deps) {
154
+ for (const dep of deps) {
155
+ if (dep === full || path.posix.basename(dep, path.posix.extname(dep)) === base) {
156
+ pages.push(page)
157
+ break
158
+ }
159
+ }
160
+ }
161
+ return pages
162
+ }
163
+
117
164
  async render(templateName, context) {
118
165
  let source
119
166
  const frontMatterResult = parseFrontMatter(templateName)
120
167
  source = frontMatterResult.content
121
168
 
122
169
  if (path.extname(templateName) === '.md') {
123
- source = renderMarkdown(source)
170
+ source = renderMarkdownCached(templateName, source)
124
171
  }
125
172
 
126
173
  const frontMatter = context.page || {}
@@ -128,13 +175,32 @@ export default class LiquidEngine {
128
175
  source = `{% layout '${frontMatter.layout}${this.fileExtension}' %}{% block content %}${source}{% endblock %}`
129
176
  }
130
177
 
131
- return this.engine.parseAndRender(source, { ...this.globals, ...context }, {
132
- globals: this.globals
133
- })
178
+ // Dep tracking: parseAndRenderSync is fully synchronous, so every parse
179
+ // cache touch in this window belongs to this page. The page's own file
180
+ // seeds the set (the page source never goes through parseFile).
181
+ const pageKey = path.resolve(templateName)
182
+ const deps = new Set([pageKey])
183
+ this._currentDeps = deps
184
+
185
+ // Sync render path: ~20% faster than parseAndRender — thousands of small
186
+ // renders pay for the promise scaffolding (toPromise/isPromise), not the
187
+ // template work. Constraint: filters/tags must be sync or generator-based
188
+ // (all of ours are); an async filter won't throw in sync mode, it silently
189
+ // stringifies to "[object Promise]". Tags eval args via liquid._evalValue
190
+ // (generator, works in both modes) — liquid.evalValue returns a promise
191
+ // and would corrupt sync renders the same way.
192
+ try {
193
+ return this.engine.parseAndRenderSync(source, { ...this.globals, ...context }, {
194
+ globals: this.globals
195
+ })
196
+ } finally {
197
+ this._currentDeps = null
198
+ this.deps.set(pageKey, deps)
199
+ }
134
200
  }
135
201
 
136
202
  async renderString(source, context) {
137
- return this.engine.parseAndRender(source, { ...this.globals, ...context }, {
203
+ return this.engine.parseAndRenderSync(source, { ...this.globals, ...context }, {
138
204
  globals: this.globals
139
205
  })
140
206
  }
@@ -148,7 +214,7 @@ function registerGoogleFontsTag(engine) {
148
214
  this.value = tagToken.args.trim()
149
215
  },
150
216
  * render(ctx) {
151
- const fonts = yield this.liquid.evalValue(this.value, ctx)
217
+ const fonts = yield this.liquid._evalValue(this.value, ctx)
152
218
  if (!fonts || (Array.isArray(fonts) && fonts.length === 0)) return ''
153
219
 
154
220
  const fontList = typeof fonts === 'string' ? [fonts] : fonts
@@ -221,7 +287,7 @@ function registerPaginationTag(engine) {
221
287
  this.value = tagToken.args.trim()
222
288
  },
223
289
  * render(ctx) {
224
- const pagination = yield this.liquid.evalValue(this.value, ctx)
290
+ const pagination = yield this.liquid._evalValue(this.value, ctx)
225
291
  const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
226
292
  return buildPaginationTag(pagination, prefix)
227
293
  }
@@ -6,29 +6,49 @@ import { discoverImageVariants, parseFrontMatter, groupby, buildImageTag, buildP
6
6
  import { getImageExif, listImages } from '../image-cache.js'
7
7
  import { toPosix } from '../../utils/helpers.js'
8
8
  import { highlightCode } from '../highlight.js'
9
- import { marked, renderMarkdown } from '../renderer.js'
9
+ import { marked, renderMarkdownCached } from '../renderer.js'
10
10
  import { slugify } from 'book-of-spells'
11
11
  import dayjs from 'dayjs'
12
12
  import log from '../../utils/log.js'
13
13
 
14
14
  class RelativeLoader extends nunjucks.Loader {
15
- constructor(templatesDir, includePaths) {
15
+ constructor(templatesDir, includePaths, onTouch) {
16
16
  super()
17
17
  this.templatesDir = templatesDir
18
18
  this.includePaths = includePaths || []
19
19
  this.includePaths.push('_*')
20
+ // Reports every compiled-template cache touch (hit or write) with the
21
+ // backing file path — the Environment reads loader.cache directly on
22
+ // hits, so getSource alone never sees reuse of already-compiled templates.
23
+ this.onTouch = onTouch || null
24
+ }
25
+
26
+ // nunjucks assigns `loader.cache = {}` and then reads/writes entries on it
27
+ // directly (Environment.getTemplate); the accessor wraps whatever it
28
+ // assigns in a proxy that feeds the dependency index.
29
+ get cache() { return this._cache }
30
+ set cache(store) {
31
+ const touch = (tmpl) => { if (tmpl && tmpl.path && this.onTouch) this.onTouch(tmpl.path) }
32
+ this._cache = new Proxy(store, {
33
+ get(target, key) { const tmpl = target[key]; touch(tmpl); return tmpl },
34
+ set(target, key, tmpl) { touch(tmpl); target[key] = tmpl; return true }
35
+ })
20
36
  }
21
37
 
22
38
  getSource(name) {
23
39
  let fullPath = name
24
40
  if (!fs.existsSync(name)) {
25
41
  let pattern = `**/${name}`
26
- if (this.includePaths) {
42
+ if (this.includePaths.length > 1) {
27
43
  pattern = `{${this.includePaths.join(',')}}/${pattern}`
44
+ } else if (this.includePaths.length === 1) {
45
+ // no braces for a single path: glob treats one-element braces
46
+ // (`{_*}`) as literal characters and the pattern never matches
47
+ pattern = `${this.includePaths[0]}/${pattern}`
28
48
  }
29
49
  fullPath = globSync(toPosix(path.join(this.templatesDir, pattern)))[0]
30
50
  }
31
- if (!fs.existsSync(fullPath)) {
51
+ if (!fullPath || !fs.existsSync(fullPath)) {
32
52
  log({ tag: 'markup', error: true, text: 'Template not found:', link: name })
33
53
  // noCache so a template created mid-watch is found on the next compile
34
54
  return { src: '', path: fullPath, noCache: true }
@@ -47,7 +67,7 @@ class RelativeLoader extends nunjucks.Loader {
47
67
  }
48
68
 
49
69
  if (path.extname(fullPath) === '.md') {
50
- source = renderMarkdown(source)
70
+ source = renderMarkdownCached(fullPath, source)
51
71
  }
52
72
 
53
73
  if (frontMatter.layout) {
@@ -55,8 +75,9 @@ class RelativeLoader extends nunjucks.Loader {
55
75
  }
56
76
 
57
77
  // Cached: getSource does glob resolution + front matter + markdown per
58
- // call, so re-running it per include per page dominates builds. Cleared
59
- // per compile (clearCache) so watch-mode edits are picked up.
78
+ // call, so re-running it per include per page dominates builds. The cache
79
+ // survives across compiles; the watcher drops a changed file's entries
80
+ // via invalidate(), so watch-mode edits are still picked up.
60
81
  return { src: source, path: fullPath, noCache: false }
61
82
  }
62
83
 
@@ -69,8 +90,17 @@ export default class NunjucksEngine {
69
90
  constructor(templatesDir, includePaths, options) {
70
91
  const autoescape = (options && options.autoescape) || false
71
92
 
93
+ // Dependency index for incremental rebuilds: while a page renders, every
94
+ // template the Environment touches (cache hits included, via the loader's
95
+ // cache proxy) lands in that page's dep set. Persists across compiles —
96
+ // each page render overwrites its own entry.
97
+ this.deps = new Map()
98
+ this._currentDeps = null
99
+
72
100
  this.env = new nunjucks.Environment(
73
- new RelativeLoader(templatesDir, includePaths),
101
+ new RelativeLoader(templatesDir, includePaths, (file) => {
102
+ if (this._currentDeps) this._currentDeps.add(file)
103
+ }),
74
104
  { autoescape, watch: false }
75
105
  )
76
106
  }
@@ -80,6 +110,22 @@ export default class NunjucksEngine {
80
110
  for (const loader of this.env.loaders) loader.cache = {}
81
111
  }
82
112
 
113
+ // Watch-mode: drop only the compiled template(s) backed by a changed or
114
+ // deleted file, instead of the whole cache being wiped per compile. Cache
115
+ // keys vary (absolute page paths, resolved include paths, bare include
116
+ // names), but every cached Template records the source path getSource
117
+ // returned — match on that. Prefix match covers a deleted directory.
118
+ invalidate(file) {
119
+ const full = path.resolve(process.cwd(), file)
120
+ for (const loader of this.env.loaders) {
121
+ for (const [name, tmpl] of Object.entries(loader.cache)) {
122
+ if (tmpl && tmpl.path && (tmpl.path === full || tmpl.path.startsWith(full + path.sep))) {
123
+ delete loader.cache[name]
124
+ }
125
+ }
126
+ }
127
+ }
128
+
83
129
  get fileExtension() { return '.njk' }
84
130
  get indexableExtensions() { return new Set(['.html', '.md', '.njk']) }
85
131
  get markupExtensions() { return 'html|xml|rss|atom|json|njk|md' }
@@ -154,16 +200,33 @@ export default class NunjucksEngine {
154
200
  delete this.env.globals[key]
155
201
  }
156
202
 
157
- render(templateName, context) {
158
- return new Promise((resolve, reject) => {
159
- this.env.getTemplate(templateName).render(context, (error, result) => {
160
- if (!error) {
161
- resolve(result)
162
- } else {
163
- reject(error)
164
- }
165
- })
166
- })
203
+ // Incremental rebuilds: pages whose last render loaded this file.
204
+ pagesDependingOn(file) {
205
+ const full = path.resolve(process.cwd(), file)
206
+ const pages = []
207
+ for (const [page, deps] of this.deps) {
208
+ if (deps.has(full)) pages.push(page)
209
+ }
210
+ return pages
211
+ }
212
+
213
+ async render(templateName, context) {
214
+ // Sync render (no callback): the callback API defers the result a tick
215
+ // per page, and dep attribution needs the render to finish inside this
216
+ // window — every template the loader cache serves in it belongs to this
217
+ // page. Constraint: filters/extensions must be sync (all of ours are;
218
+ // nunjucks throws loudly if an async one sneaks into a sync render).
219
+ // Deps kept on errors too: everything loaded up to the failure is
220
+ // recorded, so fixing the failing partial re-renders this page.
221
+ const pageKey = path.resolve(templateName)
222
+ const deps = new Set([pageKey])
223
+ this._currentDeps = deps
224
+ try {
225
+ return this.env.getTemplate(templateName).render(context)
226
+ } finally {
227
+ this._currentDeps = null
228
+ this.deps.set(pageKey, deps)
229
+ }
167
230
  }
168
231
 
169
232
  renderString(source, context) {
@@ -29,7 +29,7 @@ const DEFAULTS = {
29
29
  globalFrequencyCeiling: 0.8
30
30
  }
31
31
 
32
- const INTERNAL_FIELDS = new Set(['content', 'isIndex', 'layout', 'published'])
32
+ const INTERNAL_FIELDS = new Set(['content', 'isIndex', 'layout', 'published', '_src'])
33
33
 
34
34
  function normalizeConfig(config) {
35
35
  if (!config) return null
@@ -94,11 +94,33 @@ function escapeXml(str) {
94
94
  .replace(/'/g, '&apos;')
95
95
  }
96
96
 
97
+ // Keyword extraction survives compiles: keyed by url, validated by comparing
98
+ // rendered content (V8 string equality is a memcmp — far cheaper than the
99
+ // regex passes in extractKeywords). Rebuilt per call, so deleted pages drop
100
+ // out. ponytail: retains each page's HTML across compiles; switch to content
101
+ // hashes if memory matters on very large sites.
102
+ let keywordCache = new Map()
103
+ let keywordCacheSig = ''
104
+
105
+ // Test hook: extraction always builds a fresh array, so reference identity
106
+ // across calls is proof of a memo hit
107
+ export function _getKeywordCache() {
108
+ return keywordCache
109
+ }
110
+
97
111
  export function generateSearchIndex(pageEntries, outputDir, config) {
98
112
  config = normalizeConfig(config)
99
113
  if (!config) return
100
114
 
115
+ // Any option that changes extraction output invalidates the whole cache
116
+ const sig = JSON.stringify([config.minWordLength, config.maxKeywords, config.stopWords])
117
+ if (sig !== keywordCacheSig) {
118
+ keywordCache = new Map()
119
+ keywordCacheSig = sig
120
+ }
121
+
101
122
  const stopWords = loadStopWords(config.stopWords)
123
+ const nextCache = new Map()
102
124
 
103
125
  let entries = pageEntries
104
126
  .filter(e => !e.isIndex)
@@ -108,12 +130,20 @@ export function generateSearchIndex(pageEntries, outputDir, config) {
108
130
  if (!INTERNAL_FIELDS.has(key)) entry[key] = value
109
131
  }
110
132
  if (!entry.keywords) {
111
- entry.keywords = extractKeywords(e.content || '', { ...config, stopWords })
112
- .slice(0, config.maxKeywords)
133
+ const content = e.content || ''
134
+ const cached = keywordCache.get(e.url)
135
+ entry.keywords = cached && cached.content === content
136
+ ? cached.keywords
137
+ : extractKeywords(content, { ...config, stopWords }).slice(0, config.maxKeywords)
138
+ // Cached pre-ceiling: the frequency ceiling reassigns entry.keywords
139
+ // to a new filtered array, so this reference stays unfiltered
140
+ nextCache.set(e.url, { content, keywords: entry.keywords })
113
141
  }
114
142
  return entry
115
143
  })
116
144
 
145
+ keywordCache = nextCache
146
+
117
147
  entries = applyGlobalFrequencyCeiling(entries, config.globalFrequencyCeiling)
118
148
 
119
149
  // resolve, not join: outputDir may be absolute (join would mangle it,
@@ -69,3 +69,16 @@ marked.use(markedGithubFootnote())
69
69
  export function renderMarkdown(source) {
70
70
  return mapRawSegments(marked.parse(source), decodeTemplateEntities, (s) => s)
71
71
  }
72
+
73
+ // marked is a measurable slice of a build and its output only changes when
74
+ // the file does. Keyed by path, validated by content identity: parseFrontMatter
75
+ // hands back the same cached string object until the file's mtime changes, so
76
+ // a === check invalidates exactly when the source was actually re-read.
77
+ const markdownCache = new Map()
78
+ export function renderMarkdownCached(filePath, source) {
79
+ const cached = markdownCache.get(filePath)
80
+ if (cached && cached.source === source) return cached.html
81
+ const html = renderMarkdown(source)
82
+ markdownCache.set(filePath, { source, html })
83
+ return html
84
+ }
package/lib/markups.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
2
- import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
2
+ import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, wordcount } from './markup/helpers.js'
3
3
  import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
4
4
  import { generateIndexFiles, buildNavTree } from './markup/indexer.js'
5
5
  import NunjucksEngine from './markup/engines/nunjucks.js'
@@ -305,64 +305,79 @@ export default class Markups {
305
305
  this.engine.setGlobal('nav', buildNavTree(entries, navOptions))
306
306
  }
307
307
 
308
+ // One page: render, write, index. Shared by full directory compiles and
309
+ // incremental rebuilds. Resolves to { skipped, entry } so incremental can
310
+ // patch the previous build's entries.
311
+ compilePage(file, markupIn, collectionData, pageEntries) {
312
+ const relativePath = path.relative(markupIn, file)
313
+ const relativePathParts = relativePath.split(path.sep)
314
+
315
+ // Map before deriving dir/prefix: engines may relocate output (e.g.
316
+ // Shopify's templates/ flattening), and links must match the final path.
317
+ const markupOut = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
318
+ const fromPath = path.resolve(process.cwd(), this.markupOut)
319
+ const markupOutDir = path.dirname(markupOut)
320
+
321
+ mkDir(markupOutDir)
322
+
323
+ const fileContext = {
324
+ ...collectionData,
325
+ relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
326
+ // output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
327
+ // otherwise `item.url == page.url` sidebar/active checks never fire
328
+ _url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
329
+ }
330
+
331
+ const shouldIndex = pageEntries && this.engine.indexableExtensions.has(path.extname(file))
332
+ const fileCollection = relativePathParts.length > 1 && collectionData[relativePathParts[0]]
333
+ ? relativePathParts[0]
334
+ : null
335
+
336
+ return this.compileEntry(file, fileContext).then(({ result, frontMatter, skipped }) => {
337
+ if (skipped) {
338
+ if (fs.existsSync(markupOut)) {
339
+ fs.unlinkSync(markupOut)
340
+ log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), markupOut) })
341
+ }
342
+ return { skipped: true, entry: null }
343
+ }
344
+ // async write so I/O overlaps rendering of the other pages; the
345
+ // returned promise keeps Promise.all waiting for it
346
+ const write = fs.promises.writeFile(markupOut, result)
347
+
348
+ let entry = null
349
+ if (shouldIndex && frontMatter.published !== false) {
350
+ if (!frontMatter.title) frontMatter.title = path.basename(file, path.extname(file))
351
+ if (fileCollection && !frontMatter.collection) frontMatter.collection = fileCollection
352
+
353
+ entry = {
354
+ ...frontMatter,
355
+ url: getPageUrlRelativeToOutput(markupOut, this.markupOut),
356
+ content: result,
357
+ isIndex: false,
358
+ // source path (normalized) so incremental rebuilds can replace this
359
+ // entry; stripped from emitted artifacts (indexer INTERNAL_FIELDS)
360
+ _src: path.resolve(file)
361
+ }
362
+ pageEntries.push(entry)
363
+ }
364
+ return write.then(() => ({ skipped: false, entry }))
365
+ })
366
+ }
367
+
308
368
  async compileDirectory(markupIn, collectionData, pageEntries) {
309
369
  const markupStart = performance.now()
310
370
  const markupFiles = this.getMarkupFiles(markupIn)
311
371
  const compilePromises = []
312
- const indexableExtensions = this.engine.indexableExtensions
313
372
 
314
373
  for (const file of markupFiles) {
315
- const relativePath = path.relative(markupIn, file)
316
- const relativePathParts = relativePath.split(path.sep)
374
+ const relativePathParts = path.relative(markupIn, file).split(path.sep)
317
375
 
318
376
  if (this.isCollectionIndexOverride(relativePathParts, collectionData)) {
319
377
  continue
320
378
  }
321
379
 
322
- // Map before deriving dir/prefix: engines may relocate output (e.g.
323
- // Shopify's templates/ flattening), and links must match the final path.
324
- const markupOut = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
325
- const fromPath = path.resolve(process.cwd(), this.markupOut)
326
- const markupOutDir = path.dirname(markupOut)
327
-
328
- mkDir(markupOutDir)
329
-
330
- const fileContext = {
331
- ...collectionData,
332
- relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
333
- // output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
334
- // otherwise `item.url == page.url` sidebar/active checks never fire
335
- _url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
336
- }
337
-
338
- const shouldIndex = pageEntries && indexableExtensions.has(path.extname(file))
339
- const fileCollection = relativePathParts.length > 1 && collectionData[relativePathParts[0]]
340
- ? relativePathParts[0]
341
- : null
342
-
343
- const compilePromise = this.compileEntry(file, fileContext).then(({ result, frontMatter, skipped }) => {
344
- if (skipped) {
345
- if (fs.existsSync(markupOut)) {
346
- fs.unlinkSync(markupOut)
347
- log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), markupOut) })
348
- }
349
- return
350
- }
351
- fs.writeFileSync(markupOut, result)
352
-
353
- if (shouldIndex && frontMatter.published !== false) {
354
- if (!frontMatter.title) frontMatter.title = path.basename(file, path.extname(file))
355
- if (fileCollection && !frontMatter.collection) frontMatter.collection = fileCollection
356
-
357
- pageEntries.push({
358
- ...frontMatter,
359
- url: getPageUrlRelativeToOutput(markupOut, this.markupOut),
360
- content: result,
361
- isIndex: false
362
- })
363
- }
364
- })
365
- compilePromises.push(compilePromise)
380
+ compilePromises.push(this.compilePage(file, markupIn, collectionData, pageEntries))
366
381
  }
367
382
 
368
383
  try {
@@ -374,13 +389,28 @@ export default class Markups {
374
389
  console.error(err)
375
390
  throw err
376
391
  } finally {
377
- clearFrontMatterCache()
378
- // Engine template caches live for one compile: fast within a build,
379
- // watch-mode edits always picked up on the next one
380
- if (typeof this.engine.clearCache === 'function') this.engine.clearCache()
392
+ this.clearEngineCaches()
381
393
  }
382
394
  }
383
395
 
396
+ // Front matter/markdown/data caches are mtime- or identity-validated and
397
+ // survive across compiles — a watch rebuild only re-reads what changed.
398
+ // Engines with per-file invalidation (nunjucks) keep their compiled
399
+ // templates too — the watcher routes changed paths to invalidate(). Engines
400
+ // without it (liquid) still clear their parse cache every compile so
401
+ // watch-mode edits are always picked up.
402
+ clearEngineCaches() {
403
+ if (typeof this.engine.invalidate === 'function') return
404
+ if (typeof this.engine.clearCache === 'function') this.engine.clearCache()
405
+ }
406
+
407
+ // Watch-mode hook: drop engine state backed by one changed/deleted file.
408
+ // The mtime-validated caches need no help — this only reaches engines that
409
+ // cache compiled templates across compiles.
410
+ invalidate(file) {
411
+ if (this.engine && typeof this.engine.invalidate === 'function') this.engine.invalidate(file)
412
+ }
413
+
384
414
  async compileSingleFile(markupIn, collectionData, pageEntries) {
385
415
  const markupStart = performance.now()
386
416
  let markupOut = path.resolve(process.cwd(), this.markupOut)
@@ -428,8 +458,7 @@ export default class Markups {
428
458
  console.error(err)
429
459
  throw err
430
460
  } finally {
431
- clearFrontMatterCache()
432
- if (typeof this.engine.clearCache === 'function') this.engine.clearCache()
461
+ this.clearEngineCaches()
433
462
  }
434
463
  }
435
464
 
@@ -440,6 +469,11 @@ export default class Markups {
440
469
  await this.init()
441
470
  if (!this.engine) return
442
471
 
472
+ // Reset up front so a failed build can't leave a stale snapshot —
473
+ // compileIncremental treats a missing snapshot as "must full-compile"
474
+ this.lastCollectionData = null
475
+ this.lastPageEntries = null
476
+
443
477
  if (this.config.reactorData) {
444
478
  // A removed reactor component must also drop its injected global,
445
479
  // same staleness rule as data files in loadDataFiles()
@@ -510,5 +544,124 @@ export default class Markups {
510
544
  nav: this.navConfig
511
545
  })
512
546
  }
547
+
548
+ // Snapshot for incremental rebuilds: they render with the same
549
+ // collection/pagination context and patch these entries in place
550
+ this.lastCollectionData = collectionData
551
+ this.lastPageEntries = pageEntries
552
+ }
553
+
554
+ // Watch hook for .json/.yaml changes. Data extensions are ambiguous: a
555
+ // .json may be a data file feeding the globals, or engine-owned markup
556
+ // (Shopify templates/*.json, section-group JSON). Engines that can tell
557
+ // claim theirs via isMarkupSource() and get the incremental path;
558
+ // everything else reloads data globals and full-compiles, as before.
559
+ async compileDataChange(file) {
560
+ await this.init()
561
+ if (this.engine && typeof this.engine.isMarkupSource === 'function' &&
562
+ this.engine.isMarkupSource(path.resolve(process.cwd(), file))) {
563
+ return this.compileIncremental(file)
564
+ }
565
+ return this.reloadDataFiles().then(() => this.compile())
513
566
  }
567
+
568
+ // Watch-mode entry point: rebuild only the pages whose last render touched
569
+ // the changed file. Falls back to a full compile() whenever the result
570
+ // isn't provably identical to a full build — unsupported engine, no prior
571
+ // successful full build, deletions/renames, collection membership (listing
572
+ // and pagination pages read item content outside the dep index), an
573
+ // affected pagination-rendered template, a published flip, or front matter
574
+ // drift while a nav global is baked into every page.
575
+ async compileIncremental(changedFile) {
576
+ const moduleConfig = this.config.markup
577
+ if (!moduleConfig || !moduleConfig.in) return
578
+
579
+ await this.init()
580
+ if (!this.engine) return
581
+
582
+ if (typeof this.engine.pagesDependingOn !== 'function') return this.compile()
583
+ if (!this.lastCollectionData) return this.compile()
584
+
585
+ const markupIn = path.resolve(process.cwd(), this.markupIn)
586
+ if (!pathIsDirectory(markupIn)) return this.compile()
587
+
588
+ const abs = path.resolve(process.cwd(), changedFile)
589
+ if (!fs.existsSync(abs)) return this.compile()
590
+
591
+ // Drop the changed file's compiled template (idempotent — the watcher
592
+ // already does this, but incremental must not depend on that)
593
+ this.invalidate(abs)
594
+
595
+ const collectionData = this.lastCollectionData
596
+ const relParts = path.relative(markupIn, abs).split(path.sep)
597
+ if (relParts[0] !== '..' && collectionData[relParts[0]]) return this.compile()
598
+
599
+ let affected = this.engine.pagesDependingOn(abs)
600
+ if (affected === null) return this.compile() // dep index unreliable
601
+ affected = affected.filter((page) => fs.existsSync(page))
602
+ if (affected.length === 0) return this.compile() // unknown file — don't guess
603
+
604
+ for (const page of affected) {
605
+ const rel = path.relative(markupIn, page)
606
+ if (rel.startsWith('..')) return this.compile()
607
+ if (this.isCollectionIndexOverride(rel.split(path.sep), collectionData)) return this.compile()
608
+ }
609
+
610
+ // A page's own dep set always contains itself, so an edited page is in
611
+ // `affected` (exact match, no glob needed). If it isn't, but the file IS a
612
+ // page source, this is a new page whose basename collides with a recorded
613
+ // dep — rendering only the collided pages would leave it unbuilt.
614
+ if (!affected.includes(abs) && this.getMarkupFiles(markupIn).some((f) => path.resolve(f) === abs)) {
615
+ return this.compile()
616
+ }
617
+
618
+ const markupStart = performance.now()
619
+ const newEntries = this.lastPageEntries ? [] : null
620
+
621
+ let results
622
+ try {
623
+ results = await Promise.all(affected.map((page) => this.compilePage(page, markupIn, collectionData, newEntries)))
624
+ } catch (err) {
625
+ log({ tag: this.logTag, error: true, text: 'Failed compiling' })
626
+ console.error(err)
627
+ throw err
628
+ } finally {
629
+ this.clearEngineCaches()
630
+ }
631
+
632
+ // A skipped (published: false) page changes the page set — nav, index and
633
+ // collection listings all shift, only a full build gets them right
634
+ if (results.some((r) => r.skipped)) return this.compile()
635
+
636
+ if (this.lastPageEntries) {
637
+ for (const entry of newEntries) {
638
+ const idx = this.lastPageEntries.findIndex((e) => e._src === entry._src)
639
+ if (idx === -1) return this.compile() // page wasn't part of the last full build
640
+ // The nav global is baked into every page's HTML — front matter
641
+ // drift (title, order, …) can change it for pages we're not
642
+ // rebuilding. Content/wordcount change on every edit and never feed
643
+ // the nav, so they're excluded from the comparison.
644
+ if (this.navConfig && entrySignature(this.lastPageEntries[idx]) !== entrySignature(entry)) {
645
+ return this.compile()
646
+ }
647
+ this.lastPageEntries[idx] = entry
648
+ }
649
+
650
+ generateIndexFiles(this.lastPageEntries, this.markupOut, this.siteData.url, {
651
+ searchIndex: this.searchIndexConfig,
652
+ sitemap: this.sitemapConfig,
653
+ nav: this.navConfig
654
+ })
655
+ }
656
+
657
+ const markupEnd = performance.now()
658
+ log({ tag: this.logTag, text: `Recompiled: ${affected.length} page${affected.length > 1 ? 's' : ''} into`, link: this.markupOut, time: buildTime(markupStart, markupEnd) })
659
+ }
660
+ }
661
+
662
+ // Everything except the fields that change on every content edit — used to
663
+ // detect front matter drift that would invalidate the shared nav global
664
+ function entrySignature(entry) {
665
+ const { content, wordcount, ...rest } = entry
666
+ return JSON.stringify(rest)
514
667
  }
@@ -96,10 +96,24 @@ export function readYamlFile(filePath) {
96
96
  }
97
97
  }
98
98
 
99
+ // mtime+size-keyed memo (same pattern as the front matter cache): a watch
100
+ // change to one data file re-reads only that file instead of re-parsing every
101
+ // data file on each reload. Entries self-invalidate; callers share the parsed
102
+ // object across reloads, so treat it as read-only.
103
+ const dataFileCache = new Map()
104
+
99
105
  export function readDataFile(filePath) {
100
- if (/(\.json)$/i.test(filePath)) return readJsonFile(filePath)
101
- if (/(\.ya?ml)$/i.test(filePath)) return readYamlFile(filePath)
102
- return fs.readFileSync(filePath, 'utf8')
106
+ const stat = fs.statSync(filePath)
107
+ const cached = dataFileCache.get(filePath)
108
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.value
109
+
110
+ let value
111
+ if (/(\.json)$/i.test(filePath)) value = readJsonFile(filePath)
112
+ else if (/(\.ya?ml)$/i.test(filePath)) value = readYamlFile(filePath)
113
+ else value = fs.readFileSync(filePath, 'utf8')
114
+
115
+ dataFileCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, value })
116
+ return value
103
117
  }
104
118
 
105
119
  export function deleteDirectoryContents(directory) {
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.5.1",
4
+ "version": "1.5.3",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "main": "poops.js",
package/poops.js CHANGED
@@ -55,31 +55,54 @@ async function resolveLiveReloadPort(config) {
55
55
  config.livereload_port = liveReloadPort
56
56
  }
57
57
 
58
- function setupLiveReloadServer(config) {
59
- if (!config.livereload) return null
60
- const liveReloadExcludes = ['.git', '.svn', '.hg']
61
-
62
- if (config.watch) {
63
- liveReloadExcludes.push(...config.watch)
64
- }
65
-
66
- if (config.includePaths) {
67
- liveReloadExcludes.push(...config.includePaths)
68
- }
58
+ // The livereload server never fs-watches: watching the project meant every
59
+ // output file written during a build fired its own reload (dozens of browser
60
+ // flickers per build, some mid-write). Instead the rebuild chains in
61
+ // setupWatchers call reload() once their compile settles; the debounce folds
62
+ // the several module compiles one save triggers into a single refresh.
63
+ // 500ms: long enough to fold cascaded chains — a style/script compile writes
64
+ // into a watched copy source (Shopify theme assets/), whose chokidar event
65
+ // (awaitWriteFinish 150ms) triggers the copy-to-dist chain ~300-400ms after
66
+ // the first chain's reload. One save = one refresh, after dist is current.
67
+ //
68
+ // reload(file) collects paths over the debounce window. If everything in the
69
+ // window is .css, each path is sent so the livereload client hot-swaps
70
+ // stylesheets in place (no page reload, styles update without flicker);
71
+ // anything else escalates to one full '/' refresh.
72
+ let liveReloadServer = null
73
+ let reloadTimer = null
74
+ const reloadPaths = new Set()
75
+ function reload(file) {
76
+ if (!liveReloadServer) return
77
+ reloadPaths.add(file || '/')
78
+ clearTimeout(reloadTimer)
79
+ reloadTimer = setTimeout(() => {
80
+ const paths = [...reloadPaths]
81
+ reloadPaths.clear()
82
+ if (paths.every((p) => p.endsWith('.css'))) {
83
+ paths.forEach((p) => liveReloadServer.refresh(p))
84
+ } else {
85
+ liveReloadServer.refresh('/')
86
+ }
87
+ }, 500)
88
+ }
69
89
 
70
- if (config.livereload.exclude) {
71
- liveReloadExcludes.push(...config.livereload.exclude)
72
- }
90
+ // The css output paths of the styles entries — what the styles chain reports
91
+ // to reload() so style edits hot-swap. A directory `out` maps to the entry
92
+ // point's basename, mirroring how the styles compiler names its output file.
93
+ function styleOutputs(config) {
94
+ return [config.styles].flat()
95
+ .filter((entry) => entry && entry.in && entry.out)
96
+ .map((entry) => (path.extname(entry.out)
97
+ ? entry.out
98
+ : path.join(entry.out, path.basename(entry.in).replace(/\.(sass|scss)$/i, '.css'))))
99
+ }
73
100
 
74
- const liveReloadServer = livereload.createServer({
75
- exclusions: [...new Set(liveReloadExcludes)],
76
- port: config.livereload_port,
77
- exts: config.livereload.exts, // replaces the default watched extensions
78
- extraExts: config.livereload.extraExts // adds to the defaults
79
- })
101
+ function setupLiveReloadServer(config) {
102
+ if (!config.livereload) return
103
+ liveReloadServer = livereload.createServer({ port: config.livereload_port })
80
104
  styledLog(`🔃 {dim}LiveReload :{/} ${liveReloadServer.config.port}`)
81
105
  console.log()
82
- liveReloadServer.watch(cwd)
83
106
  }
84
107
 
85
108
  function setupWatchers(config, modules) {
@@ -105,27 +128,37 @@ function setupWatchers(config, modules) {
105
128
  const isBuildOutput = (file) => outputZones.some((zone) => pathContainsPathSegment(file, zone))
106
129
 
107
130
  const rebuild = (file) => {
131
+ // Engines that keep compiled templates across compiles (nunjucks) drop
132
+ // exactly this file's entries; shared by change/add/unlink via rebuild.
133
+ modules.markups.invalidate(file)
108
134
  if (/(\.m?jsx?|\.tsx?)$/i.test(file) && !isBuildOutput(file)) {
109
- modules.scripts.compile().catch(err => console.error(err))
135
+ modules.scripts.compile().then(() => reload()).catch(err => console.error(err))
110
136
 
111
137
  if (modules.reactor.belongsToReactor(file)) {
112
138
  modules.reactor.compile().then(() => {
113
139
  if (modules.reactor.renderedChanged) {
114
140
  config.reactorData = modules.reactor.getRendered()
115
- modules.markups.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
141
+ modules.markups.compile().then(() => modules.postcss.compile()).then(() => reload()).catch(err => console.error(err))
116
142
  }
117
143
  }).catch(err => console.error(err))
118
144
  }
119
145
  }
120
146
  if (/(\.sass|\.scss|\.css)$/i.test(file) && !isBuildOutput(file)) {
121
- modules.styles.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
147
+ modules.styles.compile().then(() => modules.postcss.compile())
148
+ .then(() => styleOutputs(config).forEach((out) => reload(out)))
149
+ .catch(err => console.error(err))
122
150
  }
123
151
  if (/(\.html|\.xml|\.rss|\.atom|\.njk|\.liquid|\.md)$/i.test(file)) {
124
- modules.markups.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
152
+ // Incremental: re-render only the pages whose last render touched this
153
+ // file; falls back to a full compile for anything it can't prove safe
154
+ // (deletions, new files, collection members, engines without dep info).
155
+ modules.markups.compileIncremental(file).then(() => modules.postcss.compile()).then(() => reload()).catch(err => console.error(err))
125
156
  }
126
157
 
127
158
  if (/(\.json|\.ya?ml)$/i.test(file)) {
128
- modules.markups.reloadDataFiles().then(() => modules.markups.compile()).catch(err => console.error(err))
159
+ // Engine-owned markup with a data extension (Shopify templates/*.json)
160
+ // goes incremental; real data files reload globals + full compile.
161
+ modules.markups.compileDataChange(file).then(() => reload()).catch(err => console.error(err))
129
162
  }
130
163
  }
131
164
 
@@ -141,9 +174,14 @@ function setupWatchers(config, modules) {
141
174
  modules.images.compile()
142
175
  .then(() => modules.markups.compile())
143
176
  .then(() => modules.postcss.compile())
177
+ .then(() => reload())
144
178
  .catch(err => console.error(err))
145
179
  }
146
- doesFileBelongToPath(file, config.copy) && modules.copy.execute().catch(err => console.error(err))
180
+ // A copied .css (e.g. the styles compiler's own output landing in a copy
181
+ // source) stays a hot-swap; any other copied file needs a full reload.
182
+ doesFileBelongToPath(file, config.copy) && modules.copy.execute()
183
+ .then(() => reload(/\.css$/i.test(file) ? file : undefined))
184
+ .catch(err => console.error(err))
147
185
  }
148
186
 
149
187
  // Atomic-save editors (rename-write) fire unlink+add for every save, so an
@@ -169,6 +207,7 @@ function setupWatchers(config, modules) {
169
207
  modules.images.remove(file)
170
208
  .then(() => modules.markups.compile())
171
209
  .then(() => modules.postcss.compile())
210
+ .then(() => reload())
172
211
  .catch(err => console.error(err))
173
212
  }
174
213
  rebuild(file)
@@ -176,9 +215,10 @@ function setupWatchers(config, modules) {
176
215
  }
177
216
 
178
217
  const handleDeletedDir = (dirPath) => {
218
+ modules.markups.invalidate(dirPath) // prefix match drops every template under it
179
219
  modules.markups.removeOutput(dirPath)
180
220
  if (doesFileBelongToPath(dirPath, config.markup)) {
181
- modules.markups.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
221
+ modules.markups.compile().then(() => modules.postcss.compile()).then(() => reload()).catch(err => console.error(err))
182
222
  }
183
223
  modules.copy.unlink(dirPath, doesFileBelongToPath(dirPath, config.copy))
184
224
  }