poops 1.5.0 → 1.5.2
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 +1 -1
- package/lib/markup/collections.js +2 -1
- package/lib/markup/engines/liquid.js +83 -9
- package/lib/markup/engines/nunjucks.js +90 -18
- package/lib/markup/indexer.js +33 -3
- package/lib/markup/renderer.js +13 -0
- package/lib/markups.js +185 -50
- package/lib/utils/helpers.js +17 -3
- package/package.json +1 -1
- package/poops.js +66 -28
package/README.md
CHANGED
|
@@ -910,7 +910,7 @@ All filters are available in both engines. The only syntax difference is how arg
|
|
|
910
910
|
|
|
911
911
|
- `jsonify` — serializes a value to JSON. Usage: `{{ myObject | jsonify }}`
|
|
912
912
|
|
|
913
|
-
- `markdown` — renders a markdown string to HTML with GitHub
|
|
913
|
+
- `markdown` — renders a markdown string to HTML with GitHub Flavored Markdown extras: emoji shortcodes (e.g. `:rocket:` → 🚀), alert callouts (`> [!NOTE]`, `[!TIP]`, `[!IMPORTANT]`, `[!WARNING]`, `[!CAUTION]`, `[!INFO]`) and footnotes (`[^1]`). Code fences are syntax-highlighted and headings get slug `id`s plus permalink anchors. Usage: `{{ "**bold** :rocket:" | markdown }}`
|
|
914
914
|
|
|
915
915
|
- `date` — formats a date string. Uses [dayjs](https://day.js.org/) format tokens. A default format can be set via the `timeDateFormat` config option.
|
|
916
916
|
- Nunjucks: `{{ "2024-01-15" | date("MMMM D, YYYY") }}`
|
|
@@ -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,10 +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
|
-
cache:
|
|
62
|
+
cache: parseCache,
|
|
31
63
|
dynamicPartials: true,
|
|
32
64
|
strictFilters: false,
|
|
33
65
|
strictVariables: false,
|
|
@@ -106,13 +138,36 @@ export default class LiquidEngine {
|
|
|
106
138
|
delete this.globals[key]
|
|
107
139
|
}
|
|
108
140
|
|
|
141
|
+
clearCache() {
|
|
142
|
+
if (this.engine.options.cache) this.engine.options.cache.clear()
|
|
143
|
+
}
|
|
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
|
+
|
|
109
164
|
async render(templateName, context) {
|
|
110
165
|
let source
|
|
111
166
|
const frontMatterResult = parseFrontMatter(templateName)
|
|
112
167
|
source = frontMatterResult.content
|
|
113
168
|
|
|
114
169
|
if (path.extname(templateName) === '.md') {
|
|
115
|
-
source =
|
|
170
|
+
source = renderMarkdownCached(templateName, source)
|
|
116
171
|
}
|
|
117
172
|
|
|
118
173
|
const frontMatter = context.page || {}
|
|
@@ -120,13 +175,32 @@ export default class LiquidEngine {
|
|
|
120
175
|
source = `{% layout '${frontMatter.layout}${this.fileExtension}' %}{% block content %}${source}{% endblock %}`
|
|
121
176
|
}
|
|
122
177
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|
|
126
200
|
}
|
|
127
201
|
|
|
128
202
|
async renderString(source, context) {
|
|
129
|
-
return this.engine.
|
|
203
|
+
return this.engine.parseAndRenderSync(source, { ...this.globals, ...context }, {
|
|
130
204
|
globals: this.globals
|
|
131
205
|
})
|
|
132
206
|
}
|
|
@@ -140,7 +214,7 @@ function registerGoogleFontsTag(engine) {
|
|
|
140
214
|
this.value = tagToken.args.trim()
|
|
141
215
|
},
|
|
142
216
|
* render(ctx) {
|
|
143
|
-
const fonts = yield this.liquid.
|
|
217
|
+
const fonts = yield this.liquid._evalValue(this.value, ctx)
|
|
144
218
|
if (!fonts || (Array.isArray(fonts) && fonts.length === 0)) return ''
|
|
145
219
|
|
|
146
220
|
const fontList = typeof fonts === 'string' ? [fonts] : fonts
|
|
@@ -213,7 +287,7 @@ function registerPaginationTag(engine) {
|
|
|
213
287
|
this.value = tagToken.args.trim()
|
|
214
288
|
},
|
|
215
289
|
* render(ctx) {
|
|
216
|
-
const pagination = yield this.liquid.
|
|
290
|
+
const pagination = yield this.liquid._evalValue(this.value, ctx)
|
|
217
291
|
const prefix = (yield ctx.get(['relativePathPrefix'])) || ''
|
|
218
292
|
return buildPaginationTag(pagination, prefix)
|
|
219
293
|
}
|
|
@@ -6,30 +6,51 @@ 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 })
|
|
53
|
+
// noCache so a template created mid-watch is found on the next compile
|
|
33
54
|
return { src: '', path: fullPath, noCache: true }
|
|
34
55
|
}
|
|
35
56
|
|
|
@@ -46,14 +67,18 @@ class RelativeLoader extends nunjucks.Loader {
|
|
|
46
67
|
}
|
|
47
68
|
|
|
48
69
|
if (path.extname(fullPath) === '.md') {
|
|
49
|
-
source =
|
|
70
|
+
source = renderMarkdownCached(fullPath, source)
|
|
50
71
|
}
|
|
51
72
|
|
|
52
73
|
if (frontMatter.layout) {
|
|
53
74
|
source = `{% extends '${frontMatter.layout}.html' %}\n{% block content %}\n${source}\n{% endblock %}`
|
|
54
75
|
}
|
|
55
76
|
|
|
56
|
-
|
|
77
|
+
// Cached: getSource does glob resolution + front matter + markdown per
|
|
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.
|
|
81
|
+
return { src: source, path: fullPath, noCache: false }
|
|
57
82
|
}
|
|
58
83
|
|
|
59
84
|
resolve(from, to) {
|
|
@@ -65,12 +90,42 @@ export default class NunjucksEngine {
|
|
|
65
90
|
constructor(templatesDir, includePaths, options) {
|
|
66
91
|
const autoescape = (options && options.autoescape) || false
|
|
67
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
|
+
|
|
68
100
|
this.env = new nunjucks.Environment(
|
|
69
|
-
new RelativeLoader(templatesDir, includePaths)
|
|
70
|
-
|
|
101
|
+
new RelativeLoader(templatesDir, includePaths, (file) => {
|
|
102
|
+
if (this._currentDeps) this._currentDeps.add(file)
|
|
103
|
+
}),
|
|
104
|
+
{ autoescape, watch: false }
|
|
71
105
|
)
|
|
72
106
|
}
|
|
73
107
|
|
|
108
|
+
clearCache() {
|
|
109
|
+
// nunjucks keeps compiled templates on each loader's cache object
|
|
110
|
+
for (const loader of this.env.loaders) loader.cache = {}
|
|
111
|
+
}
|
|
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
|
+
|
|
74
129
|
get fileExtension() { return '.njk' }
|
|
75
130
|
get indexableExtensions() { return new Set(['.html', '.md', '.njk']) }
|
|
76
131
|
get markupExtensions() { return 'html|xml|rss|atom|json|njk|md' }
|
|
@@ -145,16 +200,33 @@ export default class NunjucksEngine {
|
|
|
145
200
|
delete this.env.globals[key]
|
|
146
201
|
}
|
|
147
202
|
|
|
148
|
-
render
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
+
}
|
|
158
230
|
}
|
|
159
231
|
|
|
160
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,10 +389,28 @@ export default class Markups {
|
|
|
374
389
|
console.error(err)
|
|
375
390
|
throw err
|
|
376
391
|
} finally {
|
|
377
|
-
|
|
392
|
+
this.clearEngineCaches()
|
|
378
393
|
}
|
|
379
394
|
}
|
|
380
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
|
+
|
|
381
414
|
async compileSingleFile(markupIn, collectionData, pageEntries) {
|
|
382
415
|
const markupStart = performance.now()
|
|
383
416
|
let markupOut = path.resolve(process.cwd(), this.markupOut)
|
|
@@ -425,7 +458,7 @@ export default class Markups {
|
|
|
425
458
|
console.error(err)
|
|
426
459
|
throw err
|
|
427
460
|
} finally {
|
|
428
|
-
|
|
461
|
+
this.clearEngineCaches()
|
|
429
462
|
}
|
|
430
463
|
}
|
|
431
464
|
|
|
@@ -436,6 +469,11 @@ export default class Markups {
|
|
|
436
469
|
await this.init()
|
|
437
470
|
if (!this.engine) return
|
|
438
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
|
+
|
|
439
477
|
if (this.config.reactorData) {
|
|
440
478
|
// A removed reactor component must also drop its injected global,
|
|
441
479
|
// same staleness rule as data files in loadDataFiles()
|
|
@@ -506,5 +544,102 @@ export default class Markups {
|
|
|
506
544
|
nav: this.navConfig
|
|
507
545
|
})
|
|
508
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-mode entry point: rebuild only the pages whose last render touched
|
|
555
|
+
// the changed file. Falls back to a full compile() whenever the result
|
|
556
|
+
// isn't provably identical to a full build — unsupported engine, no prior
|
|
557
|
+
// successful full build, deletions/renames, collection membership (listing
|
|
558
|
+
// and pagination pages read item content outside the dep index), an
|
|
559
|
+
// affected pagination-rendered template, a published flip, or front matter
|
|
560
|
+
// drift while a nav global is baked into every page.
|
|
561
|
+
async compileIncremental(changedFile) {
|
|
562
|
+
const moduleConfig = this.config.markup
|
|
563
|
+
if (!moduleConfig || !moduleConfig.in) return
|
|
564
|
+
|
|
565
|
+
await this.init()
|
|
566
|
+
if (!this.engine) return
|
|
567
|
+
|
|
568
|
+
if (typeof this.engine.pagesDependingOn !== 'function') return this.compile()
|
|
569
|
+
if (!this.lastCollectionData) return this.compile()
|
|
570
|
+
|
|
571
|
+
const markupIn = path.resolve(process.cwd(), this.markupIn)
|
|
572
|
+
if (!pathIsDirectory(markupIn)) return this.compile()
|
|
573
|
+
|
|
574
|
+
const abs = path.resolve(process.cwd(), changedFile)
|
|
575
|
+
if (!fs.existsSync(abs)) return this.compile()
|
|
576
|
+
|
|
577
|
+
// Drop the changed file's compiled template (idempotent — the watcher
|
|
578
|
+
// already does this, but incremental must not depend on that)
|
|
579
|
+
this.invalidate(abs)
|
|
580
|
+
|
|
581
|
+
const collectionData = this.lastCollectionData
|
|
582
|
+
const relParts = path.relative(markupIn, abs).split(path.sep)
|
|
583
|
+
if (relParts[0] !== '..' && collectionData[relParts[0]]) return this.compile()
|
|
584
|
+
|
|
585
|
+
let affected = this.engine.pagesDependingOn(abs)
|
|
586
|
+
if (affected === null) return this.compile() // dep index unreliable
|
|
587
|
+
affected = affected.filter((page) => fs.existsSync(page))
|
|
588
|
+
if (affected.length === 0) return this.compile() // unknown file — don't guess
|
|
589
|
+
|
|
590
|
+
for (const page of affected) {
|
|
591
|
+
const rel = path.relative(markupIn, page)
|
|
592
|
+
if (rel.startsWith('..')) return this.compile()
|
|
593
|
+
if (this.isCollectionIndexOverride(rel.split(path.sep), collectionData)) return this.compile()
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const markupStart = performance.now()
|
|
597
|
+
const newEntries = this.lastPageEntries ? [] : null
|
|
598
|
+
|
|
599
|
+
let results
|
|
600
|
+
try {
|
|
601
|
+
results = await Promise.all(affected.map((page) => this.compilePage(page, markupIn, collectionData, newEntries)))
|
|
602
|
+
} catch (err) {
|
|
603
|
+
log({ tag: this.logTag, error: true, text: 'Failed compiling' })
|
|
604
|
+
console.error(err)
|
|
605
|
+
throw err
|
|
606
|
+
} finally {
|
|
607
|
+
this.clearEngineCaches()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// A skipped (published: false) page changes the page set — nav, index and
|
|
611
|
+
// collection listings all shift, only a full build gets them right
|
|
612
|
+
if (results.some((r) => r.skipped)) return this.compile()
|
|
613
|
+
|
|
614
|
+
if (this.lastPageEntries) {
|
|
615
|
+
for (const entry of newEntries) {
|
|
616
|
+
const idx = this.lastPageEntries.findIndex((e) => e._src === entry._src)
|
|
617
|
+
if (idx === -1) return this.compile() // page wasn't part of the last full build
|
|
618
|
+
// The nav global is baked into every page's HTML — front matter
|
|
619
|
+
// drift (title, order, …) can change it for pages we're not
|
|
620
|
+
// rebuilding. Content/wordcount change on every edit and never feed
|
|
621
|
+
// the nav, so they're excluded from the comparison.
|
|
622
|
+
if (this.navConfig && entrySignature(this.lastPageEntries[idx]) !== entrySignature(entry)) {
|
|
623
|
+
return this.compile()
|
|
624
|
+
}
|
|
625
|
+
this.lastPageEntries[idx] = entry
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
generateIndexFiles(this.lastPageEntries, this.markupOut, this.siteData.url, {
|
|
629
|
+
searchIndex: this.searchIndexConfig,
|
|
630
|
+
sitemap: this.sitemapConfig,
|
|
631
|
+
nav: this.navConfig
|
|
632
|
+
})
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const markupEnd = performance.now()
|
|
636
|
+
log({ tag: this.logTag, text: `Recompiled: ${affected.length} page${affected.length > 1 ? 's' : ''} into`, link: this.markupOut, time: buildTime(markupStart, markupEnd) })
|
|
509
637
|
}
|
|
510
638
|
}
|
|
639
|
+
|
|
640
|
+
// Everything except the fields that change on every content edit — used to
|
|
641
|
+
// detect front matter drift that would invalidate the shared nav global
|
|
642
|
+
function entrySignature(entry) {
|
|
643
|
+
const { content, wordcount, ...rest } = entry
|
|
644
|
+
return JSON.stringify(rest)
|
|
645
|
+
}
|
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,35 @@ 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
|
-
modules.markups.reloadDataFiles().then(() => modules.markups.compile()).catch(err => console.error(err))
|
|
159
|
+
modules.markups.reloadDataFiles().then(() => modules.markups.compile()).then(() => reload()).catch(err => console.error(err))
|
|
129
160
|
}
|
|
130
161
|
}
|
|
131
162
|
|
|
@@ -141,9 +172,14 @@ function setupWatchers(config, modules) {
|
|
|
141
172
|
modules.images.compile()
|
|
142
173
|
.then(() => modules.markups.compile())
|
|
143
174
|
.then(() => modules.postcss.compile())
|
|
175
|
+
.then(() => reload())
|
|
144
176
|
.catch(err => console.error(err))
|
|
145
177
|
}
|
|
146
|
-
|
|
178
|
+
// A copied .css (e.g. the styles compiler's own output landing in a copy
|
|
179
|
+
// source) stays a hot-swap; any other copied file needs a full reload.
|
|
180
|
+
doesFileBelongToPath(file, config.copy) && modules.copy.execute()
|
|
181
|
+
.then(() => reload(/\.css$/i.test(file) ? file : undefined))
|
|
182
|
+
.catch(err => console.error(err))
|
|
147
183
|
}
|
|
148
184
|
|
|
149
185
|
// Atomic-save editors (rename-write) fire unlink+add for every save, so an
|
|
@@ -169,6 +205,7 @@ function setupWatchers(config, modules) {
|
|
|
169
205
|
modules.images.remove(file)
|
|
170
206
|
.then(() => modules.markups.compile())
|
|
171
207
|
.then(() => modules.postcss.compile())
|
|
208
|
+
.then(() => reload())
|
|
172
209
|
.catch(err => console.error(err))
|
|
173
210
|
}
|
|
174
211
|
rebuild(file)
|
|
@@ -176,9 +213,10 @@ function setupWatchers(config, modules) {
|
|
|
176
213
|
}
|
|
177
214
|
|
|
178
215
|
const handleDeletedDir = (dirPath) => {
|
|
216
|
+
modules.markups.invalidate(dirPath) // prefix match drops every template under it
|
|
179
217
|
modules.markups.removeOutput(dirPath)
|
|
180
218
|
if (doesFileBelongToPath(dirPath, config.markup)) {
|
|
181
|
-
modules.markups.compile().then(() => modules.postcss.compile()).catch(err => console.error(err))
|
|
219
|
+
modules.markups.compile().then(() => modules.postcss.compile()).then(() => reload()).catch(err => console.error(err))
|
|
182
220
|
}
|
|
183
221
|
modules.copy.unlink(dirPath, doesFileBelongToPath(dirPath, config.copy))
|
|
184
222
|
}
|