Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

pages.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

pages.tsBlame183 lines · 1 contributor
25a91a6Claude1/**
2 * Block C3 — Pages / static hosting helpers.
3 *
4 * Exposes:
5 * - onPagesPush() — called from post-receive after a gh-pages push
6 * - resolvePagesPath() — URL-rest -> list of blob paths to probe
7 * - contentTypeFor() — extension -> mime string
8 *
9 * Deployment model: every accepted push to the configured source branch
10 * (default "gh-pages") records a row in pages_deployments. Serving reads the
11 * most recent deployment's commit sha and pulls blobs directly out of the
12 * bare repo — there is no on-disk export step.
13 */
14
15import { db } from "../db";
16import { pagesDeployments } from "../db/schema";
17
18/**
19 * Minimal extension -> MIME lookup used by the pages server. Returns
20 * "application/octet-stream" for anything not in the map so the browser
21 * will at least offer the bytes as a download instead of mis-rendering.
22 */
23export function contentTypeFor(filename: string): string {
24 const lower = filename.toLowerCase();
25 const dot = lower.lastIndexOf(".");
26 if (dot < 0) return "application/octet-stream";
27 const ext = lower.slice(dot + 1);
28 switch (ext) {
29 case "html":
30 case "htm":
31 return "text/html; charset=utf-8";
32 case "css":
33 return "text/css; charset=utf-8";
34 case "js":
35 case "mjs":
36 return "application/javascript; charset=utf-8";
37 case "json":
38 return "application/json; charset=utf-8";
39 case "svg":
40 return "image/svg+xml";
41 case "png":
42 return "image/png";
43 case "jpg":
44 case "jpeg":
45 return "image/jpeg";
46 case "gif":
47 return "image/gif";
48 case "webp":
49 return "image/webp";
50 case "ico":
51 return "image/x-icon";
52 case "txt":
53 case "md":
54 return "text/plain; charset=utf-8";
55 case "pdf":
56 return "application/pdf";
57 case "xml":
58 return "application/xml; charset=utf-8";
59 case "wasm":
60 return "application/wasm";
61 case "woff":
62 return "font/woff";
63 case "woff2":
64 return "font/woff2";
65 case "ttf":
66 return "font/ttf";
67 case "otf":
68 return "font/otf";
69 case "map":
70 return "application/json; charset=utf-8";
71 default:
72 return "application/octet-stream";
73 }
74}
75
76/**
77 * Normalise a source-dir setting to a plain prefix with no leading/trailing
78 * slashes (empty string for root).
79 */
80function normaliseSourceDir(sourceDir: string): string {
81 let d = (sourceDir || "/").trim();
82 d = d.replace(/^\/+/, "").replace(/\/+$/, "");
83 return d;
84}
85
86/**
87 * Normalise a URL-rest component. Keeps internal slashes but drops leading
88 * ones and any `..` path-traversal attempts. Returns "" for empty / pure "/".
89 */
90function normaliseUrlRest(urlRest: string): string {
91 let r = (urlRest || "").trim();
92 r = r.replace(/^\/+/, "");
93 // Strip ../ segments — cheap sanity, not a full path resolver.
94 const parts = r
95 .split("/")
96 .filter((p) => p.length > 0 && p !== "." && p !== "..");
97 return parts.join("/");
98}
99
100/**
101 * Given a URL rest-path (e.g. "", "about", "blog/first/", "assets/x.png"),
102 * return the ordered list of repo paths to try in the pages blob store.
103 * The first existing blob wins.
104 *
105 * "" -> ["index.html"]
106 * "about" -> ["about.html", "about/index.html"]
107 * "about/" -> ["about/index.html"]
108 * "a/b.css" -> ["a/b.css"]
109 * sourceDir="docs" prefixes every entry with "docs/".
110 */
111export function resolvePagesPath(
112 urlRest: string,
113 sourceDir: string,
114 indexHtml = "index.html"
115): string[] {
116 const prefix = normaliseSourceDir(sourceDir);
117 const rest = normaliseUrlRest(urlRest);
118 const endsWithSlash = /\/$/.test(urlRest || "") || urlRest === "";
119
120 const join = (p: string) => (prefix ? `${prefix}/${p}` : p);
121
122 // Root / directory-style URL -> serve the index.
123 if (rest === "") {
124 return [join(indexHtml)];
125 }
126
127 // Trailing slash or explicit dir -> only try the index inside it.
128 if (endsWithSlash) {
129 return [join(`${rest}/${indexHtml}`)];
130 }
131
132 // Has a file extension -> serve exactly that path.
133 const base = rest.split("/").pop() || "";
134 if (base.includes(".")) {
135 return [join(rest)];
136 }
137
138 // Extensionless -> pretty URL. Try foo.html first, then foo/index.html.
139 return [join(`${rest}.html`), join(`${rest}/${indexHtml}`)];
140}
141
142/**
143 * Record a pages deployment. Never throws — post-receive calls this and must
144 * not have its primary push path broken by pages bookkeeping.
145 */
146export async function onPagesPush(opts: {
147 ownerLogin: string;
148 repoName: string;
149 repositoryId: string;
150 ref: string;
151 newSha: string;
152 triggeredByUserId: string | null;
153}): Promise<void> {
154 try {
155 await db.insert(pagesDeployments).values({
156 repositoryId: opts.repositoryId,
157 ref: opts.ref,
158 commitSha: opts.newSha,
159 status: "success",
160 triggeredBy: opts.triggeredByUserId,
161 });
162 console.log(
163 `[pages] deployed ${opts.ownerLogin}/${opts.repoName} ${opts.ref}@${opts.newSha.slice(0, 7)}`
164 );
165 } catch (err) {
166 console.error(
167 `[pages] failed to record deployment for ${opts.ownerLogin}/${opts.repoName}:`,
168 err
169 );
170 // Try to record a failure row so the settings UI can surface it.
171 try {
172 await db.insert(pagesDeployments).values({
173 repositoryId: opts.repositoryId,
174 ref: opts.ref,
175 commitSha: opts.newSha,
176 status: "failed",
177 triggeredBy: opts.triggeredByUserId,
178 });
179 } catch {
180 /* swallow */
181 }
182 }
183}