poops 1.2.3 → 1.3.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.
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import yaml from 'yaml'
4
+ import { toPosix } from '../utils/helpers.js'
5
+ import { getImageEntry } from './image-cache.js'
4
6
 
5
7
  const frontMatterCache = new Map()
6
8
 
@@ -81,6 +83,25 @@ export function decodeTemplateEntities(html) {
81
83
  )
82
84
  }
83
85
 
86
+ // Builds a flat H2/H3 table of contents from rendered HTML, reading the ids
87
+ // the markdown heading renderer emits so TOC links always match the anchors.
88
+ // H3s carry a `toc-h3` class for indentation — no nested <ul>, so leading H3s
89
+ // (no parent H2) stay valid. Heading text is already entity-encoded by marked,
90
+ // so it's spliced in as-is (re-escaping would double-encode &amp;).
91
+ export function renderToc(html) {
92
+ if (!html) return ''
93
+ const re = /<h([23])\b[^>]*\sid="([^"]*)"[^>]*>([\s\S]*?)<\/h\1>/gi
94
+ let items = ''
95
+ let match
96
+ while ((match = re.exec(html)) !== null) {
97
+ const text = match[3].replace(/<[^>]*>/g, '').trim()
98
+ if (!match[2] || !text) continue
99
+ items += `<li class="toc-h${match[1]}"><a href="#${match[2]}">${text}</a></li>`
100
+ }
101
+ if (!items) return ''
102
+ return `<nav class="toc" aria-label="On this page"><ul>${items}</ul></nav>`
103
+ }
104
+
84
105
  export function groupby(arr, key, datePart) {
85
106
  if (!Array.isArray(arr)) return []
86
107
 
@@ -107,7 +128,90 @@ export function groupby(arr, key, datePart) {
107
128
 
108
129
  const FORMAT_PRIORITY = ['avif', 'webp']
109
130
 
131
+ function escapeRegExp(str) {
132
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
133
+ }
134
+
135
+ // jpeg sources compile to .jpg variants — same format for grouping purposes
136
+ function normalizeFormat(fmt) {
137
+ return fmt === 'jpeg' ? 'jpg' : fmt
138
+ }
139
+
140
+ // Pick best format for srcset (highest priority format that has variants)
141
+ // and the middle-sized original-format variant as the src fallback.
142
+ function pickSrcsetAndSrc(variants, originalExt) {
143
+ originalExt = normalizeFormat(originalExt)
144
+ variants.sort((a, b) => a.width - b.width)
145
+
146
+ const availableFormats = new Set(variants.map(v => normalizeFormat(v.format)))
147
+ let srcsetFormat = null
148
+ for (const fmt of FORMAT_PRIORITY) {
149
+ if (availableFormats.has(fmt)) {
150
+ srcsetFormat = fmt
151
+ break
152
+ }
153
+ }
154
+ if (!srcsetFormat && availableFormats.has(originalExt)) {
155
+ srcsetFormat = originalExt
156
+ }
157
+ if (!srcsetFormat && availableFormats.size > 0) {
158
+ srcsetFormat = [...availableFormats][0]
159
+ }
160
+
161
+ const srcsetVariants = srcsetFormat ? variants.filter(v => normalizeFormat(v.format) === srcsetFormat) : []
162
+
163
+ const originalVariants = variants.filter(v => normalizeFormat(v.format) === originalExt)
164
+ let srcVariant = null
165
+ if (originalVariants.length > 0) {
166
+ srcVariant = originalVariants[Math.floor((originalVariants.length - 1) / 2)]
167
+ } else if (srcsetVariants.length > 0) {
168
+ srcVariant = srcsetVariants[Math.floor((srcsetVariants.length - 1) / 2)]
169
+ }
170
+
171
+ return { srcVariant, srcsetVariants }
172
+ }
173
+
174
+ // Variant discovery from the poops-images compile cache: exact output paths
175
+ // and dimensions, no directory scan. Only `{name}-{width}w.{ext}` outputs are
176
+ // srcset material — named sizes (`-thumb-200w`) and preprocessed outputs
177
+ // (`-blurred-...`) are crops or effects with their own aspect ratios.
178
+ function discoverImageVariantsFromCache(imagePath, outputDir) {
179
+ const found = getImageEntry(imagePath, outputDir)
180
+ if (!found) return null
181
+
182
+ const { entry, prefixDir } = found
183
+ const parsed = path.parse(imagePath)
184
+ const originalExt = parsed.ext.replace('.', '')
185
+ const variantPattern = new RegExp(`^${escapeRegExp(parsed.name)}-(\\d+)w\\.([a-z0-9]+)$`)
186
+ const basePattern = new RegExp(`^${escapeRegExp(parsed.name)}\\.([a-z0-9]+)$`)
187
+
188
+ const variants = []
189
+ let base = null
190
+ for (const out of entry.outputs || []) {
191
+ const file = path.posix.basename(toPosix(out.path))
192
+ const sitePath = prefixDir ? toPosix(path.join(prefixDir, out.path)) : toPosix(out.path)
193
+ let match = file.match(variantPattern)
194
+ if (match) {
195
+ variants.push({ path: sitePath, width: parseInt(match[1], 10), height: out.height, format: match[2] })
196
+ continue
197
+ }
198
+ match = file.match(basePattern)
199
+ if (match && !base) {
200
+ base = { path: sitePath, width: out.width, height: out.height }
201
+ }
202
+ }
203
+
204
+ const { srcVariant, srcsetVariants } = pickSrcsetAndSrc(variants, originalExt)
205
+ // Base output fixes the src extension when the source was converted (heic → jpg)
206
+ const src = srcVariant || base || { path: imagePath }
207
+
208
+ return { src: src.path, variants: srcsetVariants, width: src.width, height: src.height }
209
+ }
210
+
110
211
  export function discoverImageVariants(imagePath, outputDir) {
212
+ const fromCache = discoverImageVariantsFromCache(imagePath, outputDir)
213
+ if (fromCache) return fromCache
214
+
111
215
  const parsed = path.parse(imagePath)
112
216
  const dir = path.join(outputDir, parsed.dir)
113
217
  const baseName = parsed.name
@@ -128,44 +232,14 @@ export function discoverImageVariants(imagePath, outputDir) {
128
232
  const [, name, widthStr, format] = match
129
233
  if (name !== baseName) continue
130
234
  variants.push({
131
- path: path.join(parsed.dir, file),
235
+ path: toPosix(path.join(parsed.dir, file)),
132
236
  width: parseInt(widthStr, 10),
133
237
  format
134
238
  })
135
239
  }
136
240
 
137
- variants.sort((a, b) => a.width - b.width)
138
-
139
- // Pick best format for srcset: highest priority format that has variants
140
- const availableFormats = new Set(variants.map(v => v.format))
141
- let srcsetFormat = null
142
- for (const fmt of FORMAT_PRIORITY) {
143
- if (availableFormats.has(fmt)) {
144
- srcsetFormat = fmt
145
- break
146
- }
147
- }
148
- if (!srcsetFormat && availableFormats.has(originalExt)) {
149
- srcsetFormat = originalExt
150
- }
151
- if (!srcsetFormat && availableFormats.size > 0) {
152
- srcsetFormat = [...availableFormats][0]
153
- }
154
-
155
- const srcsetVariants = srcsetFormat ? variants.filter(v => v.format === srcsetFormat) : []
156
-
157
- // Pick middle-sized variant in original format for src fallback
158
- const originalVariants = variants.filter(v => v.format === originalExt)
159
- let src = imagePath
160
- if (originalVariants.length > 0) {
161
- const mid = Math.floor((originalVariants.length - 1) / 2)
162
- src = originalVariants[mid].path
163
- } else if (srcsetVariants.length > 0) {
164
- const mid = Math.floor((srcsetVariants.length - 1) / 2)
165
- src = srcsetVariants[mid].path
166
- }
167
-
168
- return { src, variants: srcsetVariants }
241
+ const { srcVariant, srcsetVariants } = pickSrcsetAndSrc(variants, originalExt)
242
+ return { src: srcVariant ? srcVariant.path : imagePath, variants: srcsetVariants }
169
243
  }
170
244
 
171
245
  export function replaceOutExtensions(outputPath) {
@@ -200,8 +274,9 @@ export function getRelativePathPrefix(outputDir, fromDir, baseURL) {
200
274
  return baseURL.endsWith('/') ? baseURL : baseURL + '/'
201
275
  }
202
276
 
203
- let relativeDir = path.relative(process.cwd(), outputDir)
204
- const fromRelativeDir = fromDir ? path.relative(process.cwd(), fromDir) : ''
277
+ // getUpDirPrefix splits on `/`, so normalize away native separators
278
+ let relativeDir = toPosix(path.relative(process.cwd(), outputDir))
279
+ const fromRelativeDir = fromDir ? toPosix(path.relative(process.cwd(), fromDir)) : ''
205
280
 
206
281
  if (fromRelativeDir && relativeDir.startsWith(fromRelativeDir)) {
207
282
  relativeDir = relativeDir.replace(fromRelativeDir, '')
@@ -212,10 +287,62 @@ export function getRelativePathPrefix(outputDir, fromDir, baseURL) {
212
287
 
213
288
  export function getPageUrl(outputPath) {
214
289
  outputPath = replaceOutExtensions(outputPath)
215
- return /index\.[a-z]+$/.test(path.basename(outputPath)) ? path.relative(process.cwd(), path.dirname(outputPath)) : path.relative(process.cwd(), outputPath)
290
+ return toPosix(/index\.[a-z]+$/.test(path.basename(outputPath)) ? path.relative(process.cwd(), path.dirname(outputPath)) : path.relative(process.cwd(), outputPath))
216
291
  }
217
292
 
218
293
  export function getPageUrlRelativeToOutput(outputPath, outputDir) {
219
294
  const pageUrl = getPageUrl(outputPath)
220
- return path.relative(outputDir, pageUrl)
295
+ return toPosix(path.relative(outputDir, pageUrl))
296
+ }
297
+
298
+ export function escapeAttr(value) {
299
+ return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
300
+ }
301
+
302
+ export function buildPaginationTag(pagination, prefix = '') {
303
+ const totalPages = Number(pagination && pagination.totalPages) || 1
304
+ if (totalPages <= 1) return ''
305
+
306
+ const pageNumber = Number(pagination && pagination.pageNumber) || 1
307
+ const parts = []
308
+ if (pagination.prevPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.prevPageUrl)}">Previous</a>`)
309
+ parts.push(`<span>${pageNumber} of ${totalPages}</span>`)
310
+ if (pagination.nextPageUrl) parts.push(`<a href="${escapeAttr(prefix + pagination.nextPageUrl)}">Next</a>`)
311
+ return parts.join('\n')
312
+ }
313
+
314
+ // Shared by the nunjucks and liquid `image` tags — attribute values may come
315
+ // from front-matter/user data, so they are escaped here, once for both engines.
316
+ export function buildImageTag(imagePath, prefix, kwargs, getOutputDir) {
317
+ const alt = (kwargs && kwargs.alt) || ''
318
+ const loading = (kwargs && kwargs.loading) || 'lazy'
319
+ const isSvg = imagePath.endsWith('.svg')
320
+ const attrs = [`alt="${escapeAttr(alt)}"`]
321
+
322
+ if (isSvg) {
323
+ attrs.unshift(`src="${escapeAttr(prefix + imagePath)}"`)
324
+ } else {
325
+ const { src, variants, width, height } = discoverImageVariants(imagePath, getOutputDir())
326
+ const sizes = (kwargs && kwargs.sizes) || '100vw'
327
+ attrs.unshift(`src="${escapeAttr(prefix + src)}"`)
328
+ if (width && height && !(kwargs && (kwargs.width || kwargs.height))) {
329
+ attrs.push(`width="${width}"`, `height="${height}"`)
330
+ }
331
+ if (variants.length > 0) {
332
+ const srcsetVal = variants.map(v => `${prefix}${v.path} ${v.width}w`).join(', ')
333
+ attrs.push(`srcset="${escapeAttr(srcsetVal)}"`)
334
+ attrs.push(`sizes="${escapeAttr(sizes)}"`)
335
+ }
336
+ }
337
+
338
+ attrs.push(`loading="${escapeAttr(loading)}"`)
339
+ if (kwargs) {
340
+ const skip = new Set(['alt', 'sizes', 'loading'])
341
+ for (const [key, val] of Object.entries(kwargs)) {
342
+ // `__keywords` is nunjucks' kwargs marker, never a real attribute
343
+ if (key.startsWith('__') || skip.has(key)) continue
344
+ attrs.push(`${key}="${escapeAttr(val)}"`)
345
+ }
346
+ }
347
+ return `<img ${attrs.join(' ')}>`
221
348
  }
@@ -0,0 +1,114 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import { toPosix } from '../utils/helpers.js'
4
+
5
+ // Compile cache written by poops-images (https://github.com/stamat/poops-images)
6
+ // next to the images it generates. Holds exact output paths, dimensions and EXIF.
7
+ const CACHE_FILENAME = '.poops-images-cache.json'
8
+
9
+ const cacheFileCache = new Map()
10
+
11
+ function readCacheFile(cachePath) {
12
+ let stat
13
+ try {
14
+ stat = fs.statSync(cachePath)
15
+ } catch {
16
+ return null
17
+ }
18
+
19
+ const cached = cacheFileCache.get(cachePath)
20
+ if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
21
+ return cached.data
22
+ }
23
+
24
+ let data = null
25
+ try {
26
+ data = JSON.parse(fs.readFileSync(cachePath, 'utf8'))
27
+ } catch {
28
+ return null
29
+ }
30
+
31
+ cacheFileCache.set(cachePath, { mtimeMs: stat.mtimeMs, size: stat.size, data })
32
+ return data
33
+ }
34
+
35
+ export function clearImageCache() {
36
+ cacheFileCache.clear()
37
+ }
38
+
39
+ // Finds the poops-images cache entry for a site-relative image path.
40
+ // Walks from the image's directory up to outputDir, since the cache file sits
41
+ // at the root of the images output dir, which may be a subdirectory of the
42
+ // site output (e.g. dist/images/.poops-images-cache.json).
43
+ // Returns { entry, prefixDir } where prefixDir is the cache dir relative to
44
+ // outputDir (output paths in the cache are relative to the cache dir), or null.
45
+ export function getImageEntry(imagePath, outputDir) {
46
+ const root = path.resolve(outputDir)
47
+ let dir = path.resolve(root, path.dirname(imagePath))
48
+ if (!dir.startsWith(root)) return null
49
+
50
+ const target = toPosix(path.relative(root, path.resolve(root, imagePath)))
51
+ const targetNoExt = target.replace(/\.[^./]+$/, '')
52
+
53
+ while (true) {
54
+ const data = readCacheFile(path.join(dir, CACHE_FILENAME))
55
+ if (data && data.entries) {
56
+ const prefixDir = toPosix(path.relative(root, dir))
57
+ const rel = prefixDir ? target.slice(prefixDir.length + 1) : target
58
+ let entry = data.entries[rel]
59
+ if (!entry) {
60
+ // Output extension may differ from the source key (heic → jpg, jpeg → jpg)
61
+ const relNoExt = prefixDir ? targetNoExt.slice(prefixDir.length + 1) : targetNoExt
62
+ for (const [key, value] of Object.entries(data.entries)) {
63
+ if (key.replace(/\.[^./]+$/, '') === relNoExt) {
64
+ entry = value
65
+ break
66
+ }
67
+ }
68
+ }
69
+ if (entry) return { entry, prefixDir }
70
+ }
71
+ if (dir === root) return null
72
+ dir = path.dirname(dir)
73
+ }
74
+ }
75
+
76
+ export function getImageExif(imagePath, outputDir) {
77
+ const found = getImageEntry(imagePath, outputDir)
78
+ return (found && found.entry.exif) || null
79
+ }
80
+
81
+ // Lists all cache entries under a site-relative directory, gallery-ready:
82
+ // site-relative paths, `date` flattened from EXIF (file mtime fallback) so
83
+ // engine-native sort and the groupby filter work without touching nested exif.
84
+ export function listImages(dirPath, outputDir) {
85
+ const root = path.resolve(outputDir)
86
+ const clean = toPosix(dirPath || '').replace(/^\/+|\/+$/g, '')
87
+ let dir = path.resolve(root, clean)
88
+ if (!dir.startsWith(root)) return []
89
+
90
+ while (true) {
91
+ const data = readCacheFile(path.join(dir, CACHE_FILENAME))
92
+ if (data && data.entries) {
93
+ const prefixDir = toPosix(path.relative(root, dir))
94
+ const scope = toPosix(path.relative(dir, path.resolve(root, clean)))
95
+ const sitePath = (p) => prefixDir ? toPosix(path.join(prefixDir, p)) : toPosix(p)
96
+
97
+ const images = []
98
+ for (const [key, entry] of Object.entries(data.entries)) {
99
+ if (scope && !key.startsWith(scope + '/')) continue
100
+ images.push({
101
+ path: sitePath(key),
102
+ width: entry.width,
103
+ height: entry.height,
104
+ date: (entry.exif && entry.exif.dateTime) || (entry.mtime ? new Date(entry.mtime).toISOString() : null),
105
+ exif: entry.exif || null,
106
+ outputs: (entry.outputs || []).map(o => ({ ...o, path: sitePath(o.path) }))
107
+ })
108
+ }
109
+ return images
110
+ }
111
+ if (dir === root) return []
112
+ dir = path.dirname(dir)
113
+ }
114
+ }
@@ -116,7 +116,9 @@ export function generateSearchIndex(pageEntries, outputDir, config) {
116
116
 
117
117
  entries = applyGlobalFrequencyCeiling(entries, config.globalFrequencyCeiling)
118
118
 
119
- const outputPath = path.join(process.cwd(), outputDir, config.output)
119
+ // resolve, not join: outputDir may be absolute (join would mangle it,
120
+ // e.g. cross-drive temp dirs on Windows)
121
+ const outputPath = path.resolve(process.cwd(), outputDir, config.output)
120
122
  fs.writeFileSync(outputPath, JSON.stringify(entries, null, 2))
121
123
  log({ tag: 'indexer', text: 'Generated search index:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
122
124
  }
@@ -143,12 +145,166 @@ export function generateSitemap(pageEntries, outputDir, siteUrl, config) {
143
145
 
144
146
  xml += '</urlset>\n'
145
147
 
146
- const outputPath = path.join(process.cwd(), outputDir, config.output)
148
+ // resolve, not join: outputDir may be absolute (join would mangle it,
149
+ // e.g. cross-drive temp dirs on Windows)
150
+ const outputPath = path.resolve(process.cwd(), outputDir, config.output)
147
151
  fs.writeFileSync(outputPath, xml)
148
152
  log({ tag: 'indexer', text: 'Generated sitemap:', link: path.relative(process.cwd(), outputPath), size: fileSize(outputPath) })
149
153
  }
150
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
+
151
306
  export function generateIndexFiles(pageEntries, outputDir, siteUrl, config) {
152
307
  generateSearchIndex(pageEntries, outputDir, config.searchIndex)
153
308
  generateSitemap(pageEntries, outputDir, siteUrl, config.sitemap)
309
+ generateNav(pageEntries, outputDir, config.nav)
154
310
  }
@@ -0,0 +1,60 @@
1
+ import { Marked } from 'marked'
2
+ import { slugify } from 'book-of-spells'
3
+ import { highlightRenderer, highlightCode } from './highlight.js'
4
+ import { decodeTemplateEntities } from './helpers.js'
5
+
6
+ const RAW_BLOCK_RE = /\{%-?\s*raw\s*-?%\}([\s\S]*?)\{%-?\s*endraw\s*-?%\}/g
7
+
8
+ // Applies `outside` to the text between {% raw %} blocks and `inside` to each
9
+ // block's inner content, re-emitting the delimiters untouched so the template
10
+ // engine still sees them. The two places that would otherwise mangle raw
11
+ // content route through this: fence highlighting and entity decoding.
12
+ function mapRawSegments(str, outside, inside) {
13
+ RAW_BLOCK_RE.lastIndex = 0
14
+ let out = ''
15
+ let last = 0
16
+ for (const m of str.matchAll(RAW_BLOCK_RE)) {
17
+ out += outside(str.slice(last, m.index))
18
+ out += `{% raw %}${inside(m[1])}{% endraw %}`
19
+ last = m.index + m[0].length
20
+ }
21
+ return out + outside(str.slice(last))
22
+ }
23
+
24
+ // The marked renderer used across the markup engines: syntax highlighting
25
+ // (from highlight.js) plus heading slug ids + permalink anchors.
26
+ export const markdownRenderer = {
27
+ ...highlightRenderer,
28
+ // Fenced code goes through hljs, which wraps `{`/`%` in their own spans —
29
+ // splitting a {% raw %} tag so the template engine never sees it and
30
+ // evaluates the "raw" content (e.g. a {{ name }} in a config sample rendered
31
+ // as empty). Highlight around/inside each raw block instead, keeping the
32
+ // fence's language, and pass the delimiters through intact. Inline code
33
+ // never splits the delimiters, so it needs no special casing.
34
+ code(code, lang) {
35
+ const highlighted = mapRawSegments(code, (s) => s && highlightCode(s, lang), (s) => highlightCode(s, lang))
36
+ const langClass = lang ? ` language-${lang.replace(/[^\w-]/g, '')}` : ''
37
+ return `<pre><code class="hljs${langClass}">${highlighted}</code></pre>\n`
38
+ },
39
+ // Give every heading a slug id + a permalink anchor. The anchor is empty on
40
+ // purpose — themes reveal a "#" via `.heading-anchor::before`, so a site with
41
+ // no such CSS renders an invisible anchor instead of a stray "#".
42
+ // ponytail: no slug dedup — two identical headings on one page share an id;
43
+ // add a per-parse counter if that ever bites.
44
+ heading(text, level, raw) {
45
+ const id = slugify(raw || '')
46
+ if (!id) return `<h${level}>${text}</h${level}>\n`
47
+ return `<h${level} id="${id}">${text}<a class="heading-anchor" href="#${id}" aria-label="Permalink" aria-hidden="true"></a></h${level}>\n`
48
+ }
49
+ }
50
+
51
+ // One shared instance so both engines render markdown identically.
52
+ export const marked = new Marked({ renderer: markdownRenderer })
53
+
54
+ // Renders a markdown page source for the template engines. Entity decoding
55
+ // (which un-escapes inside {{ }} / {% %} so template args parse) must skip
56
+ // raw blocks — their content is for display, so its entities have to survive
57
+ // to the browser.
58
+ export function renderMarkdown(source) {
59
+ return mapRawSegments(marked.parse(source), decodeTemplateEntities, (s) => s)
60
+ }