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.
- package/lib/markup/collections.js +2 -1
- package/lib/markup/engines/liquid.js +79 -13
- package/lib/markup/engines/nunjucks.js +81 -18
- package/lib/markup/indexer.js +33 -3
- package/lib/markup/renderer.js +13 -0
- package/lib/markups.js +207 -54
- package/lib/utils/helpers.js +17 -3
- package/package.json +1 -1
- package/poops.js +68 -28
|
@@ -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
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
132
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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 =
|
|
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.
|
|
59
|
-
//
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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) {
|
package/lib/markup/indexer.js
CHANGED
|
@@ -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, ''')
|
|
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
|
-
|
|
112
|
-
|
|
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,
|
package/lib/markup/renderer.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/lib/utils/helpers.js
CHANGED
|
@@ -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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
package/poops.js
CHANGED
|
@@ -55,31 +55,54 @@ async function resolveLiveReloadPort(config) {
|
|
|
55
55
|
config.livereload_port = liveReloadPort
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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())
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|