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.
- package/README.md +395 -70
- package/lib/copy.js +5 -3
- package/lib/images.js +57 -0
- package/lib/markup/collections.js +39 -17
- package/lib/markup/engines/liquid.js +34 -33
- package/lib/markup/engines/nunjucks.js +44 -36
- package/lib/markup/helpers.js +164 -37
- package/lib/markup/image-cache.js +114 -0
- package/lib/markup/indexer.js +158 -2
- package/lib/markup/renderer.js +60 -0
- package/lib/markups.js +199 -49
- package/lib/postcss.js +16 -30
- package/lib/reactor.js +11 -29
- package/lib/scripts.js +37 -34
- package/lib/styles.js +17 -31
- package/lib/utils/helpers.js +20 -16
- package/lib/utils/log.js +15 -1
- package/lib/utils/minify.js +41 -0
- package/package.json +7 -3
- package/poops.js +120 -32
package/lib/markup/helpers.js
CHANGED
|
@@ -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 &).
|
|
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
|
-
|
|
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
|
-
|
|
204
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
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
|
+
}
|
package/lib/markup/indexer.js
CHANGED
|
@@ -116,7 +116,9 @@ export function generateSearchIndex(pageEntries, outputDir, config) {
|
|
|
116
116
|
|
|
117
117
|
entries = applyGlobalFrequencyCeiling(entries, config.globalFrequencyCeiling)
|
|
118
118
|
|
|
119
|
-
|
|
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
|
-
|
|
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
|
+
}
|