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.
@@ -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, getPageUrl, getPageUrlRelativeToOutput, parseFrontMatter, clearFrontMatterCache, wordcount } from './markup/helpers.js'
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
- const engineName = moduleConfig.engine || moduleConfig.options.engine || 'nunjucks'
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
- // Instantiate engine
41
- const EngineClass = ENGINES[engineName]
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
- log({ tag: 'error', text: `Unknown markup engine: ${engineName}` })
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.join(process.cwd(), this.markupIn)
81
+ const templatesDir = path.resolve(process.cwd(), this.markupIn)
48
82
  this.engine = new EngineClass(templatesDir, this.includePaths, {
49
- autoescape: moduleConfig.autoescape || moduleConfig.options.autoescape || false
83
+ autoescape: this.autoescape
50
84
  })
51
85
 
52
86
  this.engine.registerFilters({ timeDateFormat: this.timeDateFormat, markupOut: this.markupOut })
53
- this.engine.registerTags(() => path.join(process.cwd(), this.markupOut))
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.join(process.cwd(), dataDir, file)
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.join(process.cwd(), dataDir), f))
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.join(process.cwd(), dataDir, dataFile))
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.engine) return
135
- const markupIn = path.join(process.cwd(), this.markupIn)
136
- const rel = path.relative(markupIn, path.join(process.cwd(), sourcePath))
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.join(process.cwd(), this.markupOut, rel)
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 = replaceOutExtensions(mapped)
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
- async compileDirectory(markupIn, collectionData, pageEntries) {
207
- const markupStart = performance.now()
244
+ getMarkupFiles(markupIn) {
208
245
  // glob patterns must use `/` — on Windows `\` is an escape character
209
- const markupFiles = [
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.length > 1 &&
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
- let markupOut = path.join(process.cwd(), this.markupOut, relativePath)
228
- const fromPath = path.join(process.cwd(), this.markupOut)
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
- _url: getPageUrl(markupOut)
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
- const outFile = replaceOutExtensions(markupOut)
247
- if (fs.existsSync(outFile)) {
248
- fs.unlinkSync(outFile)
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.join(process.cwd(), this.markupOut)
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
- _url: getPageUrl(markupOut)
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 = replaceOutExtensions(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.join(process.cwd(), this.markupOut, path.basename(markupIn))), time: buildTime(markupStart, markupEnd) })
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.join(process.cwd(), this.markupOut, path.basename(markupIn))) })
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.join(process.cwd(), this.markupIn)
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
- if (!pathExists(styleEntry.in)) {
29
- log({ tag: 'style', error: true, text: 'Entry does not exist:', link: styleEntry.in })
30
- continue
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