|
| 1 | +/** biome-ignore-all lint/style/noNonNullAssertion: <explanation> */ |
1 | 2 | import kleur from "kleur"; |
2 | 3 | import type { ServerManifestRoute } from "types"; |
3 | 4 | import { logger } from "./logger"; |
4 | 5 |
|
| 6 | +type LogRouteKind = "static" | "static-dynamic" | "server-side"; |
| 7 | + |
| 8 | +type FlatRoute = Pick<ServerManifestRoute, "path"> & { kind: LogRouteKind }; |
| 9 | + |
| 10 | +type TrieNode = { |
| 11 | + name: string; // segment name ('' at root) |
| 12 | + fullPath: string; // accumulated path |
| 13 | + kind?: LogRouteKind; // only set on leaf route nodes |
| 14 | + children: Map<string, TrieNode>; |
| 15 | +}; |
| 16 | + |
| 17 | +const ICONS = { |
| 18 | + server: () => kleur.yellow("λ"), |
| 19 | + static: () => kleur.white("○"), |
| 20 | + staticDyn: () => kleur.white("●"), |
| 21 | + folder: () => kleur.white("┬"), |
| 22 | +}; |
| 23 | + |
| 24 | +const getKindIcon = (kind: LogRouteKind) => |
| 25 | + kind === "server-side" ? ICONS.server() : kind === "static" ? ICONS.static() : ICONS.staticDyn(); |
| 26 | + |
5 | 27 | export function log_routes_tree(input_routes: ServerManifestRoute[]) { |
6 | | - // Loggin Routes |
| 28 | + const flat = flattenRoutes(input_routes); |
| 29 | + |
| 30 | + // Sort lexicographically for stable grouping. |
| 31 | + flat.sort((a, b) => a.path.localeCompare(b.path)); |
| 32 | + |
| 33 | + // Build trie. |
| 34 | + const root: TrieNode = { name: "", fullPath: "", children: new Map() }; |
| 35 | + for (const r of flat) insertIntoTrie(root, r); |
| 36 | + |
| 37 | + // Header. |
7 | 38 | logger.log(kleur.bold().blue().underline("Routes")); |
| 39 | + logger.log("."); |
| 40 | + |
| 41 | + // Render. |
| 42 | + renderTrie(root, []); |
8 | 43 |
|
9 | | - function buildTree(routes: ServerManifestRoute[]) { |
10 | | - const tree: any = {}; |
11 | | - for (const route of routes) { |
12 | | - const parts = route.path === "/" ? [] : route.path.split("/").filter(Boolean); |
13 | | - let node = tree; |
14 | | - for (const part of parts) { |
15 | | - node.children = node.children || {}; |
16 | | - node.children[part] = node.children[part] || {}; |
17 | | - node = node.children[part]; |
| 44 | + // Legend. |
| 45 | + logger.log(""); |
| 46 | + logger.log(`${ICONS.server()} Server-side`); |
| 47 | + logger.log(`${ICONS.static()} Static`); |
| 48 | + logger.log(`${ICONS.staticDyn()} Static (pre-expanded variants)`); |
| 49 | +} |
| 50 | + |
| 51 | +function flattenRoutes(input: ServerManifestRoute[]): FlatRoute[] { |
| 52 | + const out: FlatRoute[] = []; |
| 53 | + for (const r of input) { |
| 54 | + if (r.rendering_kind === "static" && r.static_generated_routes.length > 0) { |
| 55 | + for (const sr of r.static_generated_routes) { |
| 56 | + out.push({ path: sr.path, kind: "static-dynamic" }); |
18 | 57 | } |
19 | | - node.route = route; |
| 58 | + } else { |
| 59 | + out.push({ path: r.path, kind: r.rendering_kind as LogRouteKind }); |
20 | 60 | } |
21 | | - return tree; |
22 | 61 | } |
| 62 | + return out; |
| 63 | +} |
23 | 64 |
|
24 | | - function printTree(node: any, prefix = "", isLast = true) { |
25 | | - if (node.route) { |
26 | | - const icon = node.route.rendering_kind === "static" ? "●" : kleur.yellow("λ"); |
27 | | - let extra = ""; |
28 | | - if ( |
29 | | - node.route.rendering_kind === "static" && |
30 | | - node.route.static_generated_routes && |
31 | | - node.route.static_generated_routes.length > 0 |
32 | | - ) { |
33 | | - extra = ` (${kleur.cyan(`${node.route.static_generated_routes.length}`)})`; |
34 | | - } |
35 | | - logger.log( |
36 | | - `${prefix}${isLast ? "└-" : "├-"} ${icon} ${kleur.white(node.route.path)}${extra}` |
37 | | - ); |
| 65 | +function insertIntoTrie(root: TrieNode, r: FlatRoute) { |
| 66 | + const segments = r.path.split("/").filter(Boolean); // ignore leading '/' |
| 67 | + let node = root; |
| 68 | + let acc = ""; |
| 69 | + for (let i = 0; i < segments.length; i++) { |
| 70 | + const seg = segments[i]; |
| 71 | + acc = acc === "" ? `/${seg}` : `${acc}/${seg}`; |
| 72 | + if (!seg) { |
| 73 | + continue; |
38 | 74 | } |
39 | | - if (node.children) { |
40 | | - const keys = Object.keys(node.children); |
41 | | - keys.forEach((key, idx) => { |
42 | | - printTree(node.children[key], prefix + (node.route ? "| " : ""), idx === keys.length - 1); |
43 | | - }); |
| 75 | + if (!node.children.has(seg)) { |
| 76 | + node.children.set(seg, { name: seg, fullPath: acc, children: new Map() }); |
44 | 77 | } |
| 78 | + node = node.children.get(seg)!; |
| 79 | + if (i === segments.length - 1) node.kind = r.kind; |
45 | 80 | } |
| 81 | +} |
46 | 82 |
|
47 | | - const tree = buildTree(input_routes); |
| 83 | +/** |
| 84 | + * Render the trie using box-drawing characters. |
| 85 | + * Additionally, group numeric leaf siblings into compact ranges. |
| 86 | + */ |
| 87 | +function renderTrie(node: TrieNode, prefixBits: boolean[]) { |
| 88 | + // Collect children into an array for deterministic index access. |
| 89 | + const children = Array.from(node.children.values()); |
48 | 90 |
|
49 | | - logger.log("."); |
50 | | - printTree(tree, "", true); |
| 91 | + // Optional numeric range grouping applied at this directory level. |
| 92 | + const [numericLeaves, others] = partition(children, (c) => isNumericLeaf(c)); |
| 93 | + const numericRanges = compressNumericLeaves(numericLeaves); |
| 94 | + |
| 95 | + // Prepare final render list with synthetic "range nodes". |
| 96 | + type RenderItem = { label: string; icon: string; isLeaf: boolean; child?: TrieNode }; |
| 97 | + const renderItems: RenderItem[] = []; |
| 98 | + |
| 99 | + // Folders and non-numeric leaves first, sorted by type then name. |
| 100 | + others.sort(byFolderThenName).forEach((child) => { |
| 101 | + const isLeaf = child.kind != null && child.children.size === 0; |
| 102 | + renderItems.push({ |
| 103 | + label: isLeaf ? kleur.white(child.name) : kleur.cyan(child.name), |
| 104 | + icon: isLeaf ? getKindIcon(child.kind!) : ICONS.folder(), |
| 105 | + isLeaf, |
| 106 | + child, |
| 107 | + }); |
| 108 | + }); |
| 109 | + |
| 110 | + // Then numeric ranges, already compressed. |
| 111 | + for (const rng of numericRanges) { |
| 112 | + const label = rng.single ? `${rng.base}/${rng.single}` : `${rng.base}/${rng.start}…${rng.end}`; |
| 113 | + renderItems.push({ |
| 114 | + label: kleur.white(label.replace(node.fullPath || "", "").replace(/^\//, "")), |
| 115 | + icon: ICONS.staticDyn(), // numeric expansions typically come from static-dynamic |
| 116 | + isLeaf: true, |
| 117 | + child: undefined, |
| 118 | + }); |
| 119 | + } |
51 | 120 |
|
52 | | - logger.log(`\n${kleur.yellow("λ")} Server-side Page`); |
53 | | - logger.log("● Static Page"); |
54 | | - logger.log(`${kleur.cyan("(#)")} Count of Static Generated Routes\n`); |
| 121 | + // Emit lines. |
| 122 | + for (let i = 0; i < renderItems.length; i++) { |
| 123 | + const it = renderItems[i]; |
| 124 | + const last = i === renderItems.length - 1; |
| 125 | + |
| 126 | + if (!it) { |
| 127 | + continue; |
| 128 | + } |
| 129 | + |
| 130 | + const branch = last ? "└─" : "├─"; |
| 131 | + const guide = prefixBits.map((b) => (b ? "│ " : " ")).join(""); |
| 132 | + |
| 133 | + logger.log(`${guide}${branch}${it.icon} ${it.label}`); |
| 134 | + |
| 135 | + // Recurse into directories. |
| 136 | + if (!it.isLeaf && it.child) { |
| 137 | + renderTrie(it.child, [...prefixBits, !last]); |
| 138 | + } |
| 139 | + } |
| 140 | +} |
| 141 | + |
| 142 | +function byFolderThenName(a: TrieNode, b: TrieNode) { |
| 143 | + const aIsFolder = a.children.size > 0 && a.kind == null; |
| 144 | + const bIsFolder = b.children.size > 0 && b.kind == null; |
| 145 | + if (aIsFolder !== bIsFolder) return aIsFolder ? -1 : 1; |
| 146 | + return a.name.localeCompare(b.name); |
| 147 | +} |
| 148 | + |
| 149 | +function partition<T>(arr: T[], pred: (x: T) => boolean): [T[], T[]] { |
| 150 | + const t: T[] = [], |
| 151 | + f: T[] = []; |
| 152 | + for (const x of arr) (pred(x) ? t : f).push(x); |
| 153 | + return [t, f]; |
| 154 | +} |
| 155 | + |
| 156 | +function isNumericLeaf(n: TrieNode): boolean { |
| 157 | + if (n.children.size !== 0) return false; |
| 158 | + if (n.kind == null) return false; |
| 159 | + return /^\d+$/.test(n.name); |
| 160 | +} |
| 161 | + |
| 162 | +type NumericRange = { base: string; start: number; end: number; single?: number }; |
| 163 | + |
| 164 | +/** |
| 165 | + * Group numeric leaves like /posts/1, /posts/2, /posts/3 into compact ranges. |
| 166 | + * We assume all nodes share the same base = parent.fullPath. |
| 167 | + */ |
| 168 | +function compressNumericLeaves(nodes: TrieNode[]): NumericRange[] { |
| 169 | + if (nodes.length === 0) return []; |
| 170 | + // Base is parent path. All siblings share it. |
| 171 | + //@ts-expect-error |
| 172 | + const base = nodes[0].fullPath.replace(/\/\d+$/, ""); |
| 173 | + const nums = nodes.map((n) => parseInt(n.name, 10)).sort((a, b) => a - b); |
| 174 | + |
| 175 | + const ranges: NumericRange[] = []; |
| 176 | + let s = nums[0]!; |
| 177 | + let prev = nums[0]!; |
| 178 | + |
| 179 | + for (let i = 1; i <= nums.length; i++) { |
| 180 | + const cur = nums[i]!; |
| 181 | + const contiguous = cur === prev + 1; |
| 182 | + if (!contiguous) { |
| 183 | + if (s === prev) ranges.push({ base, start: s, end: s, single: s }); |
| 184 | + else ranges.push({ base, start: s, end: prev }); |
| 185 | + s = cur; |
| 186 | + } |
| 187 | + prev = cur!; |
| 188 | + } |
| 189 | + return ranges; |
55 | 190 | } |
0 commit comments