poops 1.2.4 → 1.4.0
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 +220 -20
- package/lib/copy.js +1 -1
- package/lib/markup/collections.js +10 -9
- package/lib/markup/engines/liquid.js +23 -10
- package/lib/markup/engines/nunjucks.js +30 -9
- package/lib/markup/helpers.js +31 -0
- package/lib/markup/indexer.js +152 -0
- package/lib/markup/renderer.js +68 -0
- package/lib/markups.js +153 -47
- package/lib/styles.js +31 -5
- package/lib/utils/helpers.js +30 -0
- package/package.json +3 -1
- package/poops.js +17 -6
package/lib/markup/indexer.js
CHANGED
|
@@ -152,7 +152,159 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
|
|
|
152
152
|
log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
153
153
|
}
|
|
154
154
|
|
|
155
|
+
function humanizeSegment(seg) {
|
|
156
|
+
return seg
|
|
157
|
+
.replace(/[-_]+/g, ' ')
|
|
158
|
+
.replace(/\s+/g, ' ')
|
|
159
|
+
.trim()
|
|
160
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function navNodeTitle(entry) {
|
|
164
|
+
return entry.navTitle || entry.title
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Applies the `collections` option (true | false | ["name"] | "index") on top
|
|
168
|
+
// of the base exclusions (nav:false, and isIndex pages which are collection
|
|
169
|
+
// landing/pagination). "index" is the exception that re-admits each
|
|
170
|
+
// collection's first landing page as a single leaf.
|
|
171
|
+
function navFilterEntries(pageEntries, collectionsOpt) {
|
|
172
|
+
const mode = collectionsOpt === undefined ? true : collectionsOpt
|
|
173
|
+
const allowlist = Array.isArray(mode) ? new Set(mode) : null
|
|
174
|
+
const result = []
|
|
175
|
+
|
|
176
|
+
for (const e of pageEntries) {
|
|
177
|
+
if (e.nav === false) continue
|
|
178
|
+
|
|
179
|
+
if (e.isIndex) {
|
|
180
|
+
// collection landing (url === name, no slash) kept only in "index" mode;
|
|
181
|
+
// pagination pages (url has a slash) always dropped. Landing titles are
|
|
182
|
+
// the raw collection name ("blog"), so humanize for display.
|
|
183
|
+
if (mode === 'index' && !e.url.includes('/')) {
|
|
184
|
+
result.push({ ...e, title: humanizeSegment(e.title) })
|
|
185
|
+
}
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (e.collection != null) {
|
|
190
|
+
if (mode === false || mode === 'index') continue
|
|
191
|
+
if (allowlist && !allowlist.has(e.collection)) continue
|
|
192
|
+
}
|
|
193
|
+
result.push(e)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function insertNavNode(root, entry) {
|
|
200
|
+
let cursor = root
|
|
201
|
+
for (const seg of entry.url.split('/')) {
|
|
202
|
+
if (!cursor.children.has(seg)) {
|
|
203
|
+
cursor.children.set(seg, { segment: seg, children: new Map() })
|
|
204
|
+
}
|
|
205
|
+
cursor = cursor.children.get(seg)
|
|
206
|
+
}
|
|
207
|
+
cursor.hasPage = true
|
|
208
|
+
cursor.url = entry.url
|
|
209
|
+
cursor.title = navNodeTitle(entry)
|
|
210
|
+
if (entry.order != null) cursor.order = entry.order
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function getNavNode(root, urlPath) {
|
|
214
|
+
let cursor = root
|
|
215
|
+
for (const seg of urlPath.split('/')) {
|
|
216
|
+
cursor = cursor.children.get(seg)
|
|
217
|
+
if (!cursor) return null
|
|
218
|
+
}
|
|
219
|
+
return cursor
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function sortNavSiblings(nodes) {
|
|
223
|
+
nodes.sort((a, b) => {
|
|
224
|
+
const oa = a.order != null ? a.order : Infinity
|
|
225
|
+
const ob = b.order != null ? b.order : Infinity
|
|
226
|
+
if (oa !== ob) return oa - ob
|
|
227
|
+
return String(a.title).localeCompare(String(b.title))
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Post-order: children are serialized (and thus order-resolved) before the
|
|
232
|
+
// parent, so a virtual parent can borrow its first child's order.
|
|
233
|
+
function serializeNavNode(node) {
|
|
234
|
+
const children = [...node.children.values()].map(serializeNavNode)
|
|
235
|
+
sortNavSiblings(children)
|
|
236
|
+
|
|
237
|
+
const out = { title: node.title != null ? node.title : humanizeSegment(node.segment) }
|
|
238
|
+
if (node.url != null) out.url = node.url
|
|
239
|
+
|
|
240
|
+
let order = node.order
|
|
241
|
+
if (order == null && !node.hasPage && children.length) order = children[0].order
|
|
242
|
+
if (order != null) out.order = order
|
|
243
|
+
|
|
244
|
+
if (children.length) out.children = children
|
|
245
|
+
return out
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function buildNavTree(pageEntries, config = {}) {
|
|
249
|
+
const { collections, home, root } = config
|
|
250
|
+
let entries = navFilterEntries(pageEntries, collections)
|
|
251
|
+
|
|
252
|
+
if (root != null) {
|
|
253
|
+
const prefix = root + '/'
|
|
254
|
+
entries = entries.filter(e => e.url === root || e.url.startsWith(prefix))
|
|
255
|
+
} else if (home === false) {
|
|
256
|
+
entries = entries.filter(e => e.url !== '')
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const tree = { children: new Map() }
|
|
260
|
+
let homeEntry = null
|
|
261
|
+
for (const entry of entries) {
|
|
262
|
+
// root index page (url '') can't be segment-split — it would corrupt the
|
|
263
|
+
// tree; handle it as a top-level leaf instead
|
|
264
|
+
if (entry.url === '') { homeEntry = entry; continue }
|
|
265
|
+
insertNavNode(tree, entry)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// root scoping: emit the section's children unwrapped to the top level, with
|
|
269
|
+
// the section's own index page (if any) pinned first as the overview link
|
|
270
|
+
if (root != null) {
|
|
271
|
+
const rootNode = getNavNode(tree, root)
|
|
272
|
+
if (!rootNode) return []
|
|
273
|
+
const top = [...rootNode.children.values()].map(serializeNavNode)
|
|
274
|
+
sortNavSiblings(top)
|
|
275
|
+
if (rootNode.hasPage) {
|
|
276
|
+
const overview = { title: rootNode.title, url: rootNode.url }
|
|
277
|
+
if (rootNode.order != null) overview.order = rootNode.order
|
|
278
|
+
top.unshift(overview)
|
|
279
|
+
}
|
|
280
|
+
return top
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const top = [...tree.children.values()].map(serializeNavNode)
|
|
284
|
+
if (homeEntry) {
|
|
285
|
+
const node = { title: navNodeTitle(homeEntry), url: '' }
|
|
286
|
+
if (homeEntry.order != null) node.order = homeEntry.order
|
|
287
|
+
top.push(node)
|
|
288
|
+
}
|
|
289
|
+
sortNavSiblings(top)
|
|
290
|
+
return top
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function generateNav(pageEntries, outputDir, config) {
|
|
294
|
+
config = normalizeConfig(config)
|
|
295
|
+
if (!config) return
|
|
296
|
+
|
|
297
|
+
const tree = buildNavTree(pageEntries, config)
|
|
298
|
+
|
|
299
|
+
// resolve, not join: outputDir may be absolute (join would mangle it,
|
|
300
|
+
// e.g. cross-drive temp dirs on Windows)
|
|
301
|
+
const outputPath = path.resolve(process.cwd(), outputDir, config.output)
|
|
302
|
+
fs.writeFileSync(outputPath, JSON.stringify(tree, null, 2))
|
|
303
|
+
log({ tag: 'indexer', text: 'Generated nav:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
|
|
304
|
+
}
|
|
305
|
+
|
|
155
306
|
export function generateIndexFiles(pageEntries, outputDir, siteUrl, config) {
|
|
156
307
|
generateSearchIndex(pageEntries, outputDir, config.searchIndex)
|
|
157
308
|
generateSitemap(pageEntries, outputDir, siteUrl, config.sitemap)
|
|
309
|
+
generateNav(pageEntries, outputDir, config.nav)
|
|
158
310
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { Marked } from 'marked'
|
|
2
|
+
import { markedGithubEmoji } from 'marked-github-emoji'
|
|
3
|
+
import { markedGithubAlerts } from 'marked-github-alerts'
|
|
4
|
+
import { slugify } from 'book-of-spells'
|
|
5
|
+
import { highlightRenderer, highlightCode } from './highlight.js'
|
|
6
|
+
import { decodeTemplateEntities } from './helpers.js'
|
|
7
|
+
|
|
8
|
+
const RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g
|
|
9
|
+
|
|
10
|
+
// Applies `outside` to the text between {% raw %} blocks and `inside` to each
|
|
11
|
+
// block's inner content, re-emitting the delimiters untouched so the template
|
|
12
|
+
// engine still sees them. The two places that would otherwise mangle raw
|
|
13
|
+
// content route through this: fence highlighting and entity decoding.
|
|
14
|
+
function mapRawSegments(str, outside, inside) {
|
|
15
|
+
RAW_BLOCK_RE.lastIndex = 0
|
|
16
|
+
let out = ''
|
|
17
|
+
let last = 0
|
|
18
|
+
for (const m of str.matchAll(RAW_BLOCK_RE)) {
|
|
19
|
+
out += outside(str.slice(last, m.index))
|
|
20
|
+
out += `{% raw %}${inside(m[1])}{% endraw %}`
|
|
21
|
+
last = m.index + m[0].length
|
|
22
|
+
}
|
|
23
|
+
return out + outside(str.slice(last))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The marked renderer used across the markup engines: syntax highlighting
|
|
27
|
+
// (from highlight.js) plus heading slug ids + permalink anchors.
|
|
28
|
+
export const markdownRenderer = {
|
|
29
|
+
...highlightRenderer,
|
|
30
|
+
// Fenced code goes through hljs, which wraps `{`/`%` in their own spans —
|
|
31
|
+
// splitting a {% raw %} tag so the template engine never sees it and
|
|
32
|
+
// evaluates the "raw" content (e.g. a {{ name }} in a config sample rendered
|
|
33
|
+
// as empty). Highlight around/inside each raw block instead, keeping the
|
|
34
|
+
// fence's language, and pass the delimiters through intact. Inline code
|
|
35
|
+
// never splits the delimiters, so it needs no special casing.
|
|
36
|
+
code(code, lang) {
|
|
37
|
+
const highlighted = mapRawSegments(code, (s) => s && highlightCode(s, lang), (s) => highlightCode(s, lang))
|
|
38
|
+
const langClass = lang ? ` language-${lang.replace(/[^\w-]/g, '')}` : ''
|
|
39
|
+
return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>\n`
|
|
40
|
+
},
|
|
41
|
+
// Give every heading a slug id + a permalink anchor. The anchor is empty on
|
|
42
|
+
// purpose — themes reveal a "#" via `.heading-anchor::before`, so a site with
|
|
43
|
+
// no such CSS renders an invisible anchor instead of a stray "#".
|
|
44
|
+
// ponytail: no slug dedup — two identical headings on one page share an id;
|
|
45
|
+
// add a per-parse counter if that ever bites.
|
|
46
|
+
heading(text, level, raw) {
|
|
47
|
+
const id = slugify(raw || '')
|
|
48
|
+
if (!id) return `<h${level}>${text}</h${level}>\n`
|
|
49
|
+
return `<h${level} id="${id}">${text}<a class="heading-anchor" href="#${id}" aria-label="Permalink" aria-hidden="true"></a></h${level}>\n`
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// One shared instance so both engines render markdown identically.
|
|
54
|
+
export const marked = new Marked({ renderer: markdownRenderer })
|
|
55
|
+
marked.use(markedGithubEmoji())
|
|
56
|
+
marked.use(markedGithubAlerts({
|
|
57
|
+
alerts: {
|
|
58
|
+
info: { title: 'Info', icon: 'info' }
|
|
59
|
+
}
|
|
60
|
+
}))
|
|
61
|
+
|
|
62
|
+
// Renders a markdown page source for the template engines. Entity decoding
|
|
63
|
+
// (which un-escapes inside {{ }} / {% %} so template args parse) must skip
|
|
64
|
+
// raw blocks — their content is for display, so its entities have to survive
|
|
65
|
+
// to the browser.
|
|
66
|
+
export function renderMarkdown(source) {
|
|
67
|
+
return mapRawSegments(marked.parse(source), decodeTemplateEntities, (s) => s)
|
|
68
|
+
}
|
package/lib/markups.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { pathExists, pathIsDirectory, readDataFile, mkDir, buildTime, toPosix } from './utils/helpers.js'
|
|
2
|
-
import { replaceOutExtensions, getRelativePathPrefix,
|
|
2
|
+
import { replaceOutExtensions, getRelativePathPrefix, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
|
|
3
3
|
import { collectionAutoDiscovery, getCollectionDataBasedOnConfig, buildCollectionPaginationData, generateCollectionPaginationPages } from './markup/collections.js'
|
|
4
|
-
import { generateIndexFiles } from './markup/indexer.js'
|
|
4
|
+
import { generateIndexFiles, buildNavTree } from './markup/indexer.js'
|
|
5
5
|
import NunjucksEngine from './markup/engines/nunjucks.js'
|
|
6
6
|
import LiquidEngine from './markup/engines/liquid.js'
|
|
7
7
|
import fs from 'node:fs'
|
|
8
8
|
import { globSync } from 'glob'
|
|
9
9
|
import path from 'node:path'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { pathToFileURL } from 'node:url'
|
|
10
12
|
import log from './utils/log.js'
|
|
11
13
|
|
|
12
14
|
const ENGINES = {
|
|
@@ -22,8 +24,8 @@ export default class Markups {
|
|
|
22
24
|
if (!moduleConfig || !moduleConfig.in) return
|
|
23
25
|
if (!moduleConfig.options) moduleConfig.options = {}
|
|
24
26
|
|
|
25
|
-
// Determine engine
|
|
26
|
-
|
|
27
|
+
// Determine engine — resolved in init(), builtin name or importable module
|
|
28
|
+
this.engineName = moduleConfig.engine || moduleConfig.options.engine || 'nunjucks'
|
|
27
29
|
this.logTag = 'markup'
|
|
28
30
|
|
|
29
31
|
// Normalize config
|
|
@@ -35,22 +37,54 @@ export default class Markups {
|
|
|
35
37
|
this.includePaths = moduleConfig.includePaths || moduleConfig.options.includePaths || []
|
|
36
38
|
this.searchIndexConfig = moduleConfig.options.searchIndex || moduleConfig.searchIndex
|
|
37
39
|
this.sitemapConfig = moduleConfig.options.sitemap || moduleConfig.sitemap
|
|
40
|
+
this.navConfig = moduleConfig.options.nav || moduleConfig.nav
|
|
38
41
|
this.baseURL = moduleConfig.baseURL || moduleConfig.options.baseURL || null
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
this.autoescape = moduleConfig.autoescape || moduleConfig.options.autoescape || false
|
|
44
|
+
this.dataConfig = moduleConfig.data || moduleConfig.options.data
|
|
45
|
+
|
|
46
|
+
if (!moduleConfig.out) {
|
|
47
|
+
moduleConfig.out = this.markupOut
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Engine instantiation is async because non-builtin engines are loaded via
|
|
52
|
+
// dynamic import. Idempotent; compile() calls it lazily, so a failed engine
|
|
53
|
+
// resolution logs once per compile and the module stays inert.
|
|
54
|
+
async init() {
|
|
55
|
+
if (!this.markupIn || this.engine) return
|
|
56
|
+
|
|
57
|
+
let EngineClass = ENGINES[this.engineName]
|
|
42
58
|
if (!EngineClass) {
|
|
43
|
-
|
|
59
|
+
try {
|
|
60
|
+
// Relative/absolute specifiers resolve against cwd (the user's
|
|
61
|
+
// project), bare specifiers against the module graph (node_modules)
|
|
62
|
+
const spec = /^[./]/.test(this.engineName) || path.isAbsolute(this.engineName)
|
|
63
|
+
? pathToFileURL(path.resolve(process.cwd(), this.engineName)).href
|
|
64
|
+
: this.engineName
|
|
65
|
+
EngineClass = (await import(spec)).default
|
|
66
|
+
} catch {
|
|
67
|
+
// Bare specifier not reachable from poops's own module graph (e.g.
|
|
68
|
+
// poops installed via a file: link) — resolve from the user's project
|
|
69
|
+
try {
|
|
70
|
+
const projectRequire = createRequire(path.join(process.cwd(), 'package.json'))
|
|
71
|
+
EngineClass = (await import(pathToFileURL(projectRequire.resolve(this.engineName)).href)).default
|
|
72
|
+
} catch { /* handled below */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof EngineClass !== 'function') {
|
|
77
|
+
log({ tag: 'error', text: `Unknown markup engine: ${this.engineName}` })
|
|
44
78
|
return
|
|
45
79
|
}
|
|
46
80
|
|
|
47
|
-
const templatesDir = path.
|
|
81
|
+
const templatesDir = path.resolve(process.cwd(), this.markupIn)
|
|
48
82
|
this.engine = new EngineClass(templatesDir, this.includePaths, {
|
|
49
|
-
autoescape:
|
|
83
|
+
autoescape: this.autoescape
|
|
50
84
|
})
|
|
51
85
|
|
|
52
86
|
this.engine.registerFilters({ timeDateFormat: this.timeDateFormat, markupOut: this.markupOut })
|
|
53
|
-
this.engine.registerTags(() => path.
|
|
87
|
+
this.engine.registerTags(() => path.resolve(process.cwd(), this.markupOut))
|
|
54
88
|
|
|
55
89
|
// Load global variables
|
|
56
90
|
const pkgPath = path.join(process.cwd(), 'package.json')
|
|
@@ -70,12 +104,7 @@ export default class Markups {
|
|
|
70
104
|
}
|
|
71
105
|
}
|
|
72
106
|
|
|
73
|
-
this.dataConfig = moduleConfig.data || moduleConfig.options.data
|
|
74
107
|
this.loadDataFiles(this.dataConfig)
|
|
75
|
-
|
|
76
|
-
if (!moduleConfig.out) {
|
|
77
|
-
moduleConfig.out = this.markupOut
|
|
78
|
-
}
|
|
79
108
|
}
|
|
80
109
|
|
|
81
110
|
loadDataFiles(files) {
|
|
@@ -89,11 +118,11 @@ export default class Markups {
|
|
|
89
118
|
const dataDir = pathIsDirectory(this.markupIn) ? this.markupIn : path.dirname(this.markupIn)
|
|
90
119
|
const resolved = []
|
|
91
120
|
for (const file of files) {
|
|
92
|
-
const fullPath = path.
|
|
121
|
+
const fullPath = path.resolve(process.cwd(), dataDir, file)
|
|
93
122
|
if (pathIsDirectory(fullPath)) {
|
|
94
123
|
const dirFiles = globSync(toPosix(path.join(fullPath, '**/*.+(json|yml|yaml)')))
|
|
95
124
|
for (const f of dirFiles) {
|
|
96
|
-
resolved.push(path.relative(path.
|
|
125
|
+
resolved.push(path.relative(path.resolve(process.cwd(), dataDir), f))
|
|
97
126
|
}
|
|
98
127
|
} else {
|
|
99
128
|
resolved.push(file)
|
|
@@ -103,7 +132,7 @@ export default class Markups {
|
|
|
103
132
|
const loadedKeys = new Set()
|
|
104
133
|
for (const dataFile of resolved) {
|
|
105
134
|
try {
|
|
106
|
-
const data = readDataFile(path.
|
|
135
|
+
const data = readDataFile(path.resolve(process.cwd(), dataDir, dataFile))
|
|
107
136
|
const globalKeyName = path.basename(dataFile, path.extname(dataFile)).replace(/[.\-\s]/g, '_')
|
|
108
137
|
this.engine.setGlobal(globalKeyName, data)
|
|
109
138
|
loadedKeys.add(globalKeyName)
|
|
@@ -123,20 +152,29 @@ export default class Markups {
|
|
|
123
152
|
}
|
|
124
153
|
|
|
125
154
|
reloadDataFiles() {
|
|
126
|
-
this.loadDataFiles(this.dataConfig)
|
|
155
|
+
if (this.engine) this.loadDataFiles(this.dataConfig)
|
|
127
156
|
return Promise.resolve()
|
|
128
157
|
}
|
|
129
158
|
|
|
159
|
+
// Engines can override output extension mapping (e.g. a template format
|
|
160
|
+
// whose extension isn't in the default map); falls back to the shared helper
|
|
161
|
+
mapOutputPath(outputPath) {
|
|
162
|
+
if (this.engine && typeof this.engine.replaceOutExtensions === 'function') {
|
|
163
|
+
return this.engine.replaceOutExtensions(outputPath)
|
|
164
|
+
}
|
|
165
|
+
return replaceOutExtensions(outputPath)
|
|
166
|
+
}
|
|
167
|
+
|
|
130
168
|
// Maps a deleted source path to its build output and removes it.
|
|
131
169
|
// compile() only globs existing sources, so it can never clean up
|
|
132
170
|
// after a deletion — this is the only place stale output gets removed.
|
|
133
171
|
removeOutput(sourcePath) {
|
|
134
|
-
if (!this.
|
|
135
|
-
const markupIn = path.
|
|
136
|
-
const rel = path.relative(markupIn, path.
|
|
172
|
+
if (!this.markupIn) return
|
|
173
|
+
const markupIn = path.resolve(process.cwd(), this.markupIn)
|
|
174
|
+
const rel = path.relative(markupIn, path.resolve(process.cwd(), sourcePath))
|
|
137
175
|
if (rel.startsWith('..') || path.isAbsolute(rel)) return
|
|
138
176
|
|
|
139
|
-
const mapped = path.
|
|
177
|
+
const mapped = path.resolve(process.cwd(), this.markupOut, rel)
|
|
140
178
|
|
|
141
179
|
// rel === '' with a directory output would be the whole out dir —
|
|
142
180
|
// never remove that, it also holds css/js from other modules
|
|
@@ -146,7 +184,7 @@ export default class Markups {
|
|
|
146
184
|
return
|
|
147
185
|
}
|
|
148
186
|
|
|
149
|
-
const outFile =
|
|
187
|
+
const outFile = this.mapOutputPath(mapped)
|
|
150
188
|
if (fs.existsSync(outFile) && !fs.statSync(outFile).isDirectory()) {
|
|
151
189
|
fs.unlinkSync(outFile)
|
|
152
190
|
log({ tag: this.logTag, text: 'Removed:', link: path.relative(process.cwd(), outFile) })
|
|
@@ -203,13 +241,72 @@ export default class Markups {
|
|
|
203
241
|
return { result, frontMatter }
|
|
204
242
|
}
|
|
205
243
|
|
|
206
|
-
|
|
207
|
-
const markupStart = performance.now()
|
|
244
|
+
getMarkupFiles(markupIn) {
|
|
208
245
|
// glob patterns must use `/` — on Windows `\` is an escape character
|
|
209
|
-
|
|
246
|
+
return [
|
|
210
247
|
...globSync(toPosix(path.join(markupIn, this.generateMarkupGlobPattern(this.includePaths)))),
|
|
211
248
|
...globSync(toPosix(path.join(markupIn, `*.+(${this.engine.markupExtensions})`)))
|
|
212
249
|
]
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// True for collection index templates that compileDirectory skips —
|
|
253
|
+
// pagination renders them instead
|
|
254
|
+
isCollectionIndexOverride(relativePathParts, collectionData) {
|
|
255
|
+
return relativePathParts.length > 1 &&
|
|
256
|
+
collectionData[relativePathParts[0]] &&
|
|
257
|
+
relativePathParts[1].startsWith('index.') &&
|
|
258
|
+
this.engine.indexableExtensions.has(path.extname(relativePathParts[1])) &&
|
|
259
|
+
collectionData[relativePathParts[0]].items.length > 0
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Pre-pass: builds the nav tree from front matter alone (no rendering) and
|
|
263
|
+
// exposes it as the `nav` template global, so sidebars can render during the
|
|
264
|
+
// same compile instead of reading the previous build's nav.json.
|
|
265
|
+
// parseFrontMatter caches by mtime, so the compile pass re-reads nothing.
|
|
266
|
+
buildNavGlobal(markupIn, collectionData) {
|
|
267
|
+
if (!this.navConfig) return
|
|
268
|
+
|
|
269
|
+
const entries = []
|
|
270
|
+
for (const collectionName of Object.keys(collectionData)) {
|
|
271
|
+
entries.push({ url: collectionName, title: collectionName, isIndex: true })
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const files = pathIsDirectory(markupIn) ? this.getMarkupFiles(markupIn) : [markupIn]
|
|
275
|
+
for (const file of files) {
|
|
276
|
+
if (!this.engine.indexableExtensions.has(path.extname(file))) continue
|
|
277
|
+
const relativePath = path.relative(markupIn, file)
|
|
278
|
+
const relativePathParts = relativePath.split(path.sep)
|
|
279
|
+
if (this.isCollectionIndexOverride(relativePathParts, collectionData)) continue
|
|
280
|
+
|
|
281
|
+
let frontMatter
|
|
282
|
+
try {
|
|
283
|
+
frontMatter = parseFrontMatter(file).frontMatter
|
|
284
|
+
} catch (err) {
|
|
285
|
+
continue
|
|
286
|
+
}
|
|
287
|
+
if (frontMatter.published === false) continue
|
|
288
|
+
if (!frontMatter.title) frontMatter.title = path.basename(file, path.extname(file))
|
|
289
|
+
|
|
290
|
+
const fileCollection = relativePathParts.length > 1 && collectionData[relativePathParts[0]]
|
|
291
|
+
? relativePathParts[0]
|
|
292
|
+
: null
|
|
293
|
+
if (fileCollection && !frontMatter.collection) frontMatter.collection = fileCollection
|
|
294
|
+
|
|
295
|
+
const outPath = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
|
|
296
|
+
entries.push({
|
|
297
|
+
...frontMatter,
|
|
298
|
+
url: getPageUrlRelativeToOutput(outPath, this.markupOut),
|
|
299
|
+
isIndex: false
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const navOptions = typeof this.navConfig === 'object' ? this.navConfig : {}
|
|
304
|
+
this.engine.setGlobal('nav', buildNavTree(entries, navOptions))
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async compileDirectory(markupIn, collectionData, pageEntries) {
|
|
308
|
+
const markupStart = performance.now()
|
|
309
|
+
const markupFiles = this.getMarkupFiles(markupIn)
|
|
213
310
|
const compilePromises = []
|
|
214
311
|
const indexableExtensions = this.engine.indexableExtensions
|
|
215
312
|
|
|
@@ -217,15 +314,14 @@ export default class Markups {
|
|
|
217
314
|
const relativePath = path.relative(markupIn, file)
|
|
218
315
|
const relativePathParts = relativePath.split(path.sep)
|
|
219
316
|
|
|
220
|
-
if (relativePathParts
|
|
221
|
-
collectionData[relativePathParts[0]] &&
|
|
222
|
-
relativePathParts[1].startsWith('index.') && indexableExtensions.has(path.extname(relativePathParts[1])) &&
|
|
223
|
-
collectionData[relativePathParts[0]].items.length > 0) {
|
|
317
|
+
if (this.isCollectionIndexOverride(relativePathParts, collectionData)) {
|
|
224
318
|
continue
|
|
225
319
|
}
|
|
226
320
|
|
|
227
|
-
|
|
228
|
-
|
|
321
|
+
// Map before deriving dir/prefix: engines may relocate output (e.g.
|
|
322
|
+
// Shopify's templates/ flattening), and links must match the final path.
|
|
323
|
+
const markupOut = this.mapOutputPath(path.resolve(process.cwd(), this.markupOut, relativePath))
|
|
324
|
+
const fromPath = path.resolve(process.cwd(), this.markupOut)
|
|
229
325
|
const markupOutDir = path.dirname(markupOut)
|
|
230
326
|
|
|
231
327
|
mkDir(markupOutDir)
|
|
@@ -233,7 +329,9 @@ export default class Markups {
|
|
|
233
329
|
const fileContext = {
|
|
234
330
|
...collectionData,
|
|
235
331
|
relativePathPrefix: getRelativePathPrefix(markupOutDir, fromPath, this.baseURL),
|
|
236
|
-
|
|
332
|
+
// output-relative so page.url matches nav.json urls (getPageUrlRelativeToOutput),
|
|
333
|
+
// otherwise `item.url == page.url` sidebar/active checks never fire
|
|
334
|
+
_url: getPageUrlRelativeToOutput(markupOut, this.markupOut)
|
|
237
335
|
}
|
|
238
336
|
|
|
239
337
|
const shouldIndex = pageEntries && indexableExtensions.has(path.extname(file))
|
|
@@ -243,14 +341,12 @@ export default class Markups {
|
|
|
243
341
|
|
|
244
342
|
const compilePromise = this.compileEntry(file, fileContext).then(({ result, frontMatter, skipped }) => {
|
|
245
343
|
if (skipped) {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), outFile) })
|
|
344
|
+
if (fs.existsSync(markupOut)) {
|
|
345
|
+
fs.unlinkSync(markupOut)
|
|
346
|
+
log({ tag: this.logTag, text: 'Removed unpublished:', link: path.relative(process.cwd(), markupOut) })
|
|
250
347
|
}
|
|
251
348
|
return
|
|
252
349
|
}
|
|
253
|
-
markupOut = replaceOutExtensions(markupOut)
|
|
254
350
|
fs.writeFileSync(markupOut, result)
|
|
255
351
|
|
|
256
352
|
if (shouldIndex && frontMatter.published !== false) {
|
|
@@ -283,7 +379,7 @@ export default class Markups {
|
|
|
283
379
|
|
|
284
380
|
async compileSingleFile(markupIn, collectionData, pageEntries) {
|
|
285
381
|
const markupStart = performance.now()
|
|
286
|
-
let markupOut = path.
|
|
382
|
+
let markupOut = path.resolve(process.cwd(), this.markupOut)
|
|
287
383
|
const markupOutDir = path.dirname(markupOut)
|
|
288
384
|
const indexableExtensions = this.engine.indexableExtensions
|
|
289
385
|
mkDir(markupOutDir)
|
|
@@ -291,14 +387,15 @@ export default class Markups {
|
|
|
291
387
|
const fileContext = {
|
|
292
388
|
...collectionData,
|
|
293
389
|
relativePathPrefix: getRelativePathPrefix(markupOutDir, null, this.baseURL),
|
|
294
|
-
|
|
390
|
+
// output-relative so page.url matches nav.json/index urls, same as compileDirectory
|
|
391
|
+
_url: getPageUrlRelativeToOutput(this.mapOutputPath(markupOut), this.markupOut)
|
|
295
392
|
}
|
|
296
393
|
|
|
297
394
|
const shouldIndex = pageEntries && indexableExtensions.has(path.extname(markupIn))
|
|
298
395
|
|
|
299
396
|
try {
|
|
300
397
|
const { result, frontMatter, skipped } = await this.compileEntry(markupIn, fileContext)
|
|
301
|
-
markupOut =
|
|
398
|
+
markupOut = this.mapOutputPath(markupOut)
|
|
302
399
|
|
|
303
400
|
if (skipped) {
|
|
304
401
|
if (fs.existsSync(markupOut)) {
|
|
@@ -321,9 +418,9 @@ export default class Markups {
|
|
|
321
418
|
}
|
|
322
419
|
|
|
323
420
|
const markupEnd = performance.now()
|
|
324
|
-
log({ tag: this.logTag, text: 'Compiled:', link: path.relative(process.cwd(), path.
|
|
421
|
+
log({ tag: this.logTag, text: 'Compiled:', link: path.relative(process.cwd(), path.resolve(process.cwd(), this.markupOut, path.basename(markupIn))), time: buildTime(markupStart, markupEnd) })
|
|
325
422
|
} catch (err) {
|
|
326
|
-
log({ tag: this.logTag, error: true, text: 'Failed compiling:', link: path.relative(process.cwd(), path.
|
|
423
|
+
log({ tag: this.logTag, error: true, text: 'Failed compiling:', link: path.relative(process.cwd(), path.resolve(process.cwd(), this.markupOut, path.basename(markupIn))) })
|
|
327
424
|
console.error(err)
|
|
328
425
|
throw err
|
|
329
426
|
} finally {
|
|
@@ -335,6 +432,9 @@ export default class Markups {
|
|
|
335
432
|
const moduleConfig = this.config.markup
|
|
336
433
|
if (!moduleConfig || !moduleConfig.in) return
|
|
337
434
|
|
|
435
|
+
await this.init()
|
|
436
|
+
if (!this.engine) return
|
|
437
|
+
|
|
338
438
|
if (this.config.reactorData) {
|
|
339
439
|
// A removed reactor component must also drop its injected global,
|
|
340
440
|
// same staleness rule as data files in loadDataFiles()
|
|
@@ -351,7 +451,7 @@ export default class Markups {
|
|
|
351
451
|
}
|
|
352
452
|
}
|
|
353
453
|
|
|
354
|
-
const markupIn = path.
|
|
454
|
+
const markupIn = path.resolve(process.cwd(), this.markupIn)
|
|
355
455
|
|
|
356
456
|
if (!pathExists(markupIn)) {
|
|
357
457
|
log({ tag: 'error', text: 'Markup path does not exist:', link: markupIn })
|
|
@@ -363,10 +463,15 @@ export default class Markups {
|
|
|
363
463
|
...getCollectionDataBasedOnConfig(this.markupIn, this.collectionsConfig)
|
|
364
464
|
}
|
|
365
465
|
|
|
366
|
-
const shouldIndex = this.searchIndexConfig || this.sitemapConfig
|
|
466
|
+
const shouldIndex = this.searchIndexConfig || this.sitemapConfig || this.navConfig
|
|
367
467
|
const pageEntries = shouldIndex ? [] : null
|
|
368
468
|
|
|
369
469
|
buildCollectionPaginationData(collectionData)
|
|
470
|
+
|
|
471
|
+
// must precede any rendering (incl. pagination pages) so every template
|
|
472
|
+
// sees the current build's nav
|
|
473
|
+
this.buildNavGlobal(markupIn, collectionData)
|
|
474
|
+
|
|
370
475
|
const collectionPromises = generateCollectionPaginationPages(collectionData, this.markupIn, this.markupOut, this.compileEntry.bind(this), this.baseURL)
|
|
371
476
|
|
|
372
477
|
await Promise.all(collectionPromises)
|
|
@@ -396,7 +501,8 @@ export default class Markups {
|
|
|
396
501
|
if (shouldIndex && pageEntries) {
|
|
397
502
|
generateIndexFiles(pageEntries, this.markupOut, this.siteData.url, {
|
|
398
503
|
searchIndex: this.searchIndexConfig,
|
|
399
|
-
sitemap: this.sitemapConfig
|
|
504
|
+
sitemap: this.sitemapConfig,
|
|
505
|
+
nav: this.navConfig
|
|
400
506
|
})
|
|
401
507
|
}
|
|
402
508
|
}
|
package/lib/styles.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
|
+
import { globSync, hasMagic } from 'glob'
|
|
2
3
|
import {
|
|
3
4
|
pathExists,
|
|
4
5
|
mkPath,
|
|
6
|
+
pathForFile,
|
|
5
7
|
buildStyleOutputFilePath,
|
|
6
8
|
fillBannerTemplate,
|
|
7
9
|
buildTime,
|
|
@@ -25,12 +27,35 @@ export default class Styles {
|
|
|
25
27
|
this.config.styles = Array.isArray(this.config.styles) ? this.config.styles : [this.config.styles]
|
|
26
28
|
for (const styleEntry of this.config.styles) {
|
|
27
29
|
if (!styleEntry.in || !styleEntry.out) continue
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
// `in` may be an array of entry points and/or globs — same resolution as scripts
|
|
31
|
+
const configured = Array.isArray(styleEntry.in) ? styleEntry.in : [styleEntry.in]
|
|
32
|
+
const entryPoints = []
|
|
33
|
+
let missing = false
|
|
34
|
+
for (const entry of configured) {
|
|
35
|
+
if (hasMagic(entry)) {
|
|
36
|
+
// Globs must use `/` even on Windows; sort for deterministic build order.
|
|
37
|
+
// Skip sass partials (_*.scss) — they are imports, not entry points.
|
|
38
|
+
const matches = globSync(entry, { posix: true }).filter(match => !path.basename(match).startsWith('_')).sort()
|
|
39
|
+
if (!matches.length) {
|
|
40
|
+
log({ tag: 'style', error: true, text: 'Entry does not exist:', link: entry })
|
|
41
|
+
missing = true
|
|
42
|
+
}
|
|
43
|
+
entryPoints.push(...matches)
|
|
44
|
+
} else if (!pathExists(entry)) {
|
|
45
|
+
log({ tag: 'style', error: true, text: 'Entry does not exist:', link: entry })
|
|
46
|
+
missing = true
|
|
47
|
+
} else {
|
|
48
|
+
entryPoints.push(entry)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (missing) continue
|
|
52
|
+
if (entryPoints.length > 1 && pathForFile(styleEntry.out)) {
|
|
53
|
+
log({ tag: 'error', text: 'Cannot output multiple style files to a single file. Please specify an output directory path instead.' })
|
|
54
|
+
process.exit(1)
|
|
55
|
+
}
|
|
56
|
+
for (const entryPoint of entryPoints) {
|
|
57
|
+
await this.compileEntry(entryPoint, styleEntry.out, styleEntry.options)
|
|
31
58
|
}
|
|
32
|
-
mkPath(styleEntry.out)
|
|
33
|
-
await this.compileEntry(styleEntry.in, styleEntry.out, styleEntry.options)
|
|
34
59
|
}
|
|
35
60
|
}
|
|
36
61
|
|
|
@@ -58,6 +83,7 @@ export default class Styles {
|
|
|
58
83
|
}
|
|
59
84
|
|
|
60
85
|
outfilePath = buildStyleOutputFilePath(infilePath, outfilePath)
|
|
86
|
+
mkPath(outfilePath) // resolved file path — mkPath on a dir out is a no-op
|
|
61
87
|
|
|
62
88
|
const stylesStart = performance.now()
|
|
63
89
|
let compiledSass
|