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}