A simple, folder-driven static-site engine.
bun ssg fs
at dev 345 lines 10 kB view raw
1/** 2 * Apply site-level sorting (including date-based sorting) after history is known. 3 */ 4 5import type { Site, Collection, Entry, Block } from "../model"; 6import type { Logger } from "../../logging/logger"; 7import { cleanName, orderKey, slugify } from "../../utils/naming"; 8import { toPosix } from "../../utils/path"; 9 10type SortOrder = "asc" | "desc"; 11type SortType = "name" | "created" | "updated"; 12type SortSpec = { type: SortType; order: SortOrder }; 13 14type SortingSection = { 15 type?: unknown; 16 order?: unknown; 17 custom?: unknown; 18}; 19 20type SortingConfig = { 21 type?: unknown; 22 order?: unknown; 23 collections?: SortingSection; 24 entries?: SortingSection; 25 blocks?: SortingSection; 26}; 27 28type CustomSorting = { 29 collection?: unknown; 30 entry?: unknown; 31 type?: unknown; 32 order?: unknown; 33}; 34 35const DEFAULT_SORT: SortSpec = { type: "name", order: "asc" }; 36 37function normalizePath(value: string): string { 38 return toPosix(value.replace(/\\/g, "/")); 39} 40 41function normalizeSortOrder(value: unknown): SortOrder | undefined { 42 return value === "asc" || value === "desc" ? value : undefined; 43} 44 45function normalizeSortType( 46 value: unknown, 47 scope: string, 48 warned: Set<string>, 49 logger?: Logger 50): SortType | undefined { 51 if (value === undefined || value === null) return undefined; 52 if (value === "name" || value === "created" || value === "updated") return value; 53 const key = `${scope}:${String(value)}`; 54 if (!warned.has(key)) { 55 warned.add(key); 56 logger?.warn("scan.sortingUnknownType", { scope, type: value, fallback: "name" }); 57 } 58 return "name"; 59} 60 61function pickType( 62 candidates: Array<{ value: unknown; scope: string }>, 63 warned: Set<string>, 64 logger?: Logger 65): SortType | undefined { 66 for (const candidate of candidates) { 67 if (candidate.value === undefined || candidate.value === null) continue; 68 return normalizeSortType(candidate.value, candidate.scope, warned, logger) ?? "name"; 69 } 70 return undefined; 71} 72 73function pickOrder(candidates: Array<{ value: unknown }>): SortOrder | undefined { 74 for (const candidate of candidates) { 75 const order = normalizeSortOrder(candidate.value); 76 if (order) return order; 77 } 78 return undefined; 79} 80 81function resolveSortSpec( 82 candidates: { 83 type: Array<{ value: unknown; scope: string }>; 84 order: Array<{ value: unknown }>; 85 }, 86 warned: Set<string>, 87 logger?: Logger 88): SortSpec { 89 const type = pickType(candidates.type, warned, logger) ?? "name"; 90 const order = pickOrder(candidates.order) ?? "asc"; 91 return { type, order }; 92} 93 94function findCustomSortingForCollection( 95 raw: unknown, 96 collectionKey: string | undefined 97): CustomSorting | undefined { 98 if (!collectionKey || !Array.isArray(raw)) return undefined; 99 for (const item of raw) { 100 if (!item || typeof item !== "object") continue; 101 const collection = (item as Record<string, unknown>).collection; 102 if (typeof collection === "string" && collection === collectionKey) { 103 return item as CustomSorting; 104 } 105 } 106 return undefined; 107} 108 109function findCustomSortingForEntry( 110 raw: unknown, 111 collectionKey: string | undefined, 112 entryKey: string | undefined 113): CustomSorting | undefined { 114 if (!collectionKey || !Array.isArray(raw)) return undefined; 115 let collectionMatch: CustomSorting | undefined; 116 for (const item of raw) { 117 if (!item || typeof item !== "object") continue; 118 const record = item as Record<string, unknown>; 119 const collection = record.collection; 120 if (typeof collection !== "string" || collection !== collectionKey) continue; 121 const entry = record.entry; 122 if (typeof entry === "string") { 123 if (entryKey && entry === entryKey) { 124 return item as CustomSorting; 125 } 126 continue; 127 } 128 if (!collectionMatch) { 129 collectionMatch = item as CustomSorting; 130 } 131 } 132 return collectionMatch; 133} 134 135function buildCollectionKeyIndex(site: Site): Map<string, string> { 136 const map = new Map<string, string>(); 137 const raw = site.config?.collections; 138 if (!raw || typeof raw !== "object") return map; 139 140 for (const [key, value] of Object.entries(raw)) { 141 if (!value || typeof value !== "object") continue; 142 const cfg = value as Record<string, unknown>; 143 const normalizedPath = 144 typeof cfg.path === "string" && cfg.path ? normalizePath(cfg.path) : normalizePath(key); 145 map.set(normalizedPath, key); 146 } 147 148 return map; 149} 150 151function resolveCollectionKey( 152 collection: Collection, 153 index: Map<string, string> 154): string | undefined { 155 return ( 156 index.get(collection.path) ?? 157 index.get(normalizePath(collection.rawName)) ?? 158 collection.slug ?? 159 collection.id 160 ); 161} 162 163function entryKeyFromEntry(entry: Entry): string { 164 if (entry.slug && entry.slug.trim()) return entry.slug; 165 return slugify(cleanName(entry.rawName)); 166} 167 168function resolveCollectionSort( 169 sorting: SortingConfig | undefined, 170 warned: Set<string>, 171 logger?: Logger 172): SortSpec { 173 if (!sorting) return DEFAULT_SORT; 174 return resolveSortSpec( 175 { 176 type: [ 177 { value: sorting.collections?.type, scope: "sorting.collections.type" }, 178 { value: sorting.type, scope: "sorting.type" }, 179 ], 180 order: [{ value: sorting.collections?.order }, { value: sorting.order }], 181 }, 182 warned, 183 logger 184 ); 185} 186 187function resolveEntrySort( 188 sorting: SortingConfig | undefined, 189 collectionKey: string | undefined, 190 warned: Set<string>, 191 logger?: Logger 192): SortSpec { 193 if (!sorting) return DEFAULT_SORT; 194 const custom = findCustomSortingForCollection(sorting.entries?.custom, collectionKey); 195 const customScope = collectionKey 196 ? `sorting.entries.custom.type:${collectionKey}` 197 : "sorting.entries.custom.type"; 198 return resolveSortSpec( 199 { 200 type: [ 201 { value: custom?.type, scope: customScope }, 202 { value: sorting.entries?.type, scope: "sorting.entries.type" }, 203 { value: sorting.type, scope: "sorting.type" }, 204 ], 205 order: [ 206 { value: custom?.order }, 207 { value: sorting.entries?.order }, 208 { value: sorting.order }, 209 ], 210 }, 211 warned, 212 logger 213 ); 214} 215 216function resolveBlockSort( 217 sorting: SortingConfig | undefined, 218 collectionKey: string | undefined, 219 entryKey: string | undefined, 220 warned: Set<string>, 221 logger?: Logger 222): SortSpec { 223 if (!sorting) return DEFAULT_SORT; 224 const custom = findCustomSortingForEntry(sorting.blocks?.custom, collectionKey, entryKey); 225 const customScope = collectionKey 226 ? entryKey 227 ? `sorting.blocks.custom.type:${collectionKey}:${entryKey}` 228 : `sorting.blocks.custom.type:${collectionKey}` 229 : "sorting.blocks.custom.type"; 230 return resolveSortSpec( 231 { 232 type: [ 233 { value: custom?.type, scope: customScope }, 234 { value: sorting.blocks?.type, scope: "sorting.blocks.type" }, 235 { value: sorting.type, scope: "sorting.type" }, 236 ], 237 order: [ 238 { value: custom?.order }, 239 { value: sorting.blocks?.order }, 240 { value: sorting.order }, 241 ], 242 }, 243 warned, 244 logger 245 ); 246} 247 248function compareByName( 249 a: { rawName: string }, 250 b: { rawName: string }, 251 order: SortOrder 252): number { 253 const dir = order === "desc" ? -1 : 1; 254 const orderA = orderKey(a.rawName); 255 const orderB = orderKey(b.rawName); 256 if (orderA !== orderB) return (orderA - orderB) * dir; 257 return a.rawName.localeCompare(b.rawName, undefined, { sensitivity: "base" }) * dir; 258} 259 260function toTimestamp(value: unknown): number | null { 261 if (typeof value !== "string" || !value.trim()) return null; 262 const ts = Date.parse(value); 263 return Number.isFinite(ts) ? ts : null; 264} 265 266function compareByDate( 267 a: { rawName: string; createdAt?: string; updatedAt?: string }, 268 b: { rawName: string; createdAt?: string; updatedAt?: string }, 269 field: "createdAt" | "updatedAt", 270 order: SortOrder 271): number { 272 const aTs = toTimestamp(a[field]); 273 const bTs = toTimestamp(b[field]); 274 if (aTs === null && bTs === null) return compareByName(a, b, order); 275 if (aTs === null) return 1; 276 if (bTs === null) return -1; 277 if (aTs === bTs) return compareByName(a, b, order); 278 return order === "asc" ? aTs - bTs : bTs - aTs; 279} 280 281function sortList<T extends { rawName: string; createdAt?: string; updatedAt?: string }>( 282 items: T[], 283 sort: SortSpec 284): T[] { 285 if (items.length < 2) return items; 286 const sorted = [...items]; 287 if (sort.type === "name") { 288 sorted.sort((a, b) => compareByName(a, b, sort.order)); 289 return sorted; 290 } 291 const field = sort.type === "created" ? "createdAt" : "updatedAt"; 292 sorted.sort((a, b) => compareByDate(a, b, field, sort.order)); 293 return sorted; 294} 295 296function sortBlocks(blocks: Block[] | undefined, sort: SortSpec): Block[] | undefined { 297 if (!blocks) return blocks; 298 const sorted = sortList(blocks, sort); 299 return sorted.map((block) => { 300 if (!block.subBlocks) return block; 301 const subBlocks = sortBlocks(block.subBlocks, sort); 302 return subBlocks === block.subBlocks ? block : { ...block, subBlocks }; 303 }); 304} 305 306export function applySorting(site: Site, logger?: Logger): Site { 307 const sortingRaw = 308 site?.site && typeof site.site === "object" 309 ? (site.site as Record<string, unknown>).sorting 310 : undefined; 311 const sorting = 312 sortingRaw && typeof sortingRaw === "object" 313 ? (sortingRaw as SortingConfig) 314 : undefined; 315 if (!sorting) return site; 316 317 const warned = new Set<string>(); 318 const collectionKeyIndex = buildCollectionKeyIndex(site); 319 const collectionSort = resolveCollectionSort(sorting, warned, logger); 320 321 const collections = site.collections ? sortList(site.collections, collectionSort) : undefined; 322 if (!collections) return site; 323 324 const sortedCollections = collections.map((collection) => { 325 const collectionKey = resolveCollectionKey(collection, collectionKeyIndex); 326 const entrySort = resolveEntrySort(sorting, collectionKey, warned, logger); 327 const entries = sortList(collection.entries ?? [], entrySort).map((entry) => { 328 const entryKey = entryKeyFromEntry(entry); 329 const blockSort = resolveBlockSort(sorting, collectionKey, entryKey, warned, logger); 330 const blocks = sortBlocks(entry.blocks, blockSort) ?? entry.blocks; 331 return blocks === entry.blocks ? entry : { ...entry, blocks }; 332 }); 333 334 const assetsSort = resolveBlockSort(sorting, collectionKey, undefined, warned, logger); 335 const assets = collection.assets ? sortBlocks(collection.assets, assetsSort) : undefined; 336 337 return { 338 ...collection, 339 entries, 340 ...(assets ? { assets } : {}), 341 }; 342 }); 343 344 return { ...site, collections: sortedCollections }; 345}