A simple, folder-driven static-site engine.
bun
ssg
fs
1/**
2 * Route generation for collections/entries/blocks (slug-based, config-aware).
3 */
4import type { Block, Collection, Entry, Site } from "./model";
5
6type CollectionConfig = {
7 path?: string;
8 name?: string;
9 route?: string;
10};
11
12function normalizePath(value: unknown): string | undefined {
13 return typeof value === "string" && value ? value.replace(/\\/g, "/") : undefined;
14}
15
16function normalizeRouteSegment(value: unknown): string | undefined {
17 if (typeof value !== "string") return undefined;
18 const trimmed = value.trim().replace(/^\/+|\/+$/g, "");
19 return trimmed || undefined;
20}
21
22function routeSegments(base: string): string[] {
23 return base.split("/").filter(Boolean);
24}
25
26function joinRoute(...segments: string[]): string {
27 return segments.filter(Boolean).join("/");
28}
29
30function ensureLeadingSlash(value: string): string {
31 return value.startsWith("/") ? value : `/${value}`;
32}
33
34function resolveBlockRoutes(block: Block, parentSegments: string[]): Block {
35 // Blocks inherit the parent route segments for structure, but the route itself
36 // is set later by resolvers/processors when needed.
37 const segments = [...parentSegments, block.slug];
38 const subBlocks = block.subBlocks?.map((sub) => resolveBlockRoutes(sub, segments));
39
40 return {
41 ...block,
42 ...(subBlocks ? { subBlocks } : {}),
43 };
44}
45
46function resolveEntryRoutes(entry: Entry, collectionRoute: string): Entry {
47 // Entries live under the collection route; blocks nest beneath entries.
48 const baseSegments = routeSegments(collectionRoute);
49 const parentSegments = [...baseSegments, entry.slug];
50 const route = ensureLeadingSlash(`${joinRoute(...parentSegments)}/`);
51 const blocks = entry.blocks.map((block) =>
52 resolveBlockRoutes(block, parentSegments)
53 );
54
55 return {
56 ...entry,
57 route,
58 blocks,
59 };
60}
61
62function resolveCollectionRoutes(
63 collection: Collection,
64 config?: Record<string, unknown>
65): Collection {
66 const collectionConfig = config as CollectionConfig | undefined;
67 const configuredRoute = normalizeRouteSegment(collectionConfig?.route);
68
69 const baseRoute = configuredRoute ?? collection.slug;
70 const route = ensureLeadingSlash(`${joinRoute(baseRoute)}/`);
71 const entries = collection.entries.map((entry) =>
72 resolveEntryRoutes(entry, baseRoute)
73 );
74
75 return {
76 ...collection,
77 route,
78 entries,
79 };
80}
81
82function buildConfigIndex(site: Site): Map<string, CollectionConfig> {
83 // Build a lookup keyed by configured path (or collection key) for fast matching.
84 const map = new Map<string, CollectionConfig>();
85 const raw = site.config?.collections;
86 if (!raw || typeof raw !== "object") return map;
87
88 for (const [key, value] of Object.entries(raw)) {
89 if (!value || typeof value !== "object") continue;
90 const cfg = value as CollectionConfig;
91 const path = normalizePath(cfg.path) ?? key;
92 map.set(path, cfg);
93 }
94
95 return map;
96}
97
98export function resolveRoutes(site: Site): Site {
99 const configIndex = buildConfigIndex(site);
100
101 const collections = site.collections?.map((collection) => {
102 // Match config by normalized path (preferred) or rawName as fallback.
103 const matchConfig =
104 configIndex.get(collection.path) ??
105 configIndex.get(normalizePath(collection.rawName) ?? "");
106 return resolveCollectionRoutes(collection, matchConfig);
107 });
108
109 return {
110 ...site,
111 route: "/",
112 ...(collections ? { collections } : {}),
113 };
114}