char/sorcery

static-files based git repo viewer

git clone https://git.t4t.associates/char/sorcery

Charlotte Somexperiment: support sha-256 oids in git repos42f80d8

main
4.8 KiB118 linesraw
1/**
2 * where views live. the static page is the fallback and the hash is a delta
3 * on it: pieces missing from the hash (revision, path, kind) come from the
4 * pathname, so `/u/r/ref/main/blob/x#history` is x's history from main's tip
5 * and `/u/r/ref/main/blob/x#<oid>` is x at that commit.
6 */
7import type { GitRepo } from "./git/repo.ts";
8import { objectAt } from "./git/walk.ts";
9
10export type ViewKind = "tree" | "blob" | "history" | "commit" | "language";
11/** for `language`, `path` is the single language id */
12export interface View {
13  kind: ViewKind;
14  oid: string;
15  path: string[];
16}
17
18/** what the pathname says, plus the ref it stands for */
19export interface Page {
20  base: string;
21  /** the ref's root page: `/u/r/` when that is where we are, else `/u/r/ref/<ref>/` */
22  rootBase: string;
23  /** where the ref's tree and blob pages live: always `/u/r/ref/<ref>/` */
24  refBase: string;
25  tip: string | null;
26  kind: "tree" | "blob";
27  path: string[];
28}
29
30export interface Site {
31  repo: GitRepo;
32  page: Page;
33  name: string;
34}
35
36const OID = "([0-9a-f]{40}|[0-9a-f]{64})";
37export const encodePath = (path: string[]) => path.map(encodeURIComponent).join("/");
38const decodePath = (path: string) => path.split("/").filter(Boolean).map(decodeURIComponent);
39
40/** `ref` and `tip` come from `<main data-ref data-tip>` */
41export function pageAt(pathname: string, ref: string | null, tip: string | null): Page {
42  const [user, repo, ...rest] = pathname.split("/").filter(Boolean);
43  const base = `/${user}/${repo}`;
44  if (rest[0] !== "ref") {
45    const refBase = ref ? `${base}/ref/${encodePath(ref.split("/"))}/` : `${base}/`;
46    return { base, rootBase: `${base}/`, refBase, tip, kind: "tree", path: [] };
47  }
48  // ref names may contain slashes, so the ref runs up to the tree/blob marker
49  const marker = rest.findIndex((seg, i) => i > 1 && (seg === "tree" || seg === "blob"));
50  const refBase = `${base}/${(marker < 0 ? rest : rest.slice(0, marker)).join("/")}/`;
51  const page = { base, rootBase: refBase, refBase, tip };
52  if (marker < 0) return { ...page, kind: "tree", path: [] };
53  return { ...page, kind: rest[marker] as "tree" | "blob", path: decodePath(rest.slice(marker + 1).join("/")) };
54}
55
56export function viewAt(page: Page, hash: string): View | null {
57  const match = (pattern: string) => hash.match(new RegExp(`^#${pattern}$`));
58  let m: RegExpMatchArray | null;
59  if ((m = match(OID))) return { kind: page.kind, oid: m[1], path: page.path };
60  if ((m = match(`commit/${OID}`))) return { kind: "commit", oid: m[1], path: [] };
61  if ((m = match(`history(?:/${OID})?`))) {
62    const oid = m[1] ?? page.tip;
63    return oid ? { kind: "history", oid, path: page.path } : null;
64  }
65  if ((m = match(`language/([a-z0-9-]+)(?:/${OID})?`))) {
66    const oid = m[2] ?? page.tip;
67    return oid ? { kind: "language", oid, path: [m[1]] } : null;
68  }
69  if ((m = match(`(tree|blob|history)/${OID}/(.*)`))) {
70    return { kind: m[1] as ViewKind, oid: m[2], path: decodePath(m[3]) };
71  }
72  if ((m = match(`tree/${OID}`))) return { kind: "tree", oid: m[1], path: [] };
73  return null;
74}
75
76/**
77 * the shortest url for `view` that is still a real page without JS: the
78 * static page itself when it shows the same thing, else the closest static
79 * page with the difference in the hash. `atTip` is what `view.path` is at
80 * the page's tip, if anything; hash-only results keep the current pathname.
81 */
82export function canonical(page: Page, view: View, atTip: "tree" | "blob" | null): string {
83  const { kind, oid, path } = view;
84  const rev = oid === page.tip ? "" : `/${oid}`;
85  const pageFor = (kind: "tree" | "blob") =>
86    kind === "blob"
87      ? `${page.refBase}blob/${encodePath(path)}`
88      : path.length > 0
89      ? `${page.refBase}tree/${encodePath(path)}/`
90      : page.rootBase;
91  switch (kind) {
92    case "commit":
93      return `#commit/${oid}`;
94    case "language":
95      return `${page.rootBase}#language/${path[0]}${rev}`;
96    case "history":
97      return atTip ? `${pageFor(atTip)}#history${rev}` : `#history/${oid}/${encodePath(path)}`;
98    default:
99      return atTip === kind
100        ? `${pageFor(kind)}${oid === page.tip ? "" : `#${oid}`}`
101        : `#${kind}/${oid}/${encodePath(path)}`;
102  }
103}
104
105export async function kindAtTip(site: Site, path: string[]): Promise<"tree" | "blob" | null> {
106  if (!site.page.tip) return null;
107  if (path.length === 0) return "tree";
108  const commit = await site.repo.commit(site.page.tip);
109  const object = await objectAt(site.repo, commit.tree, path);
110  if (!object) return null;
111  const kind = object.mode & 0o170000;
112  return kind === 0o040000 ? "tree" : kind === 0o160000 ? null : "blob";
113}
114
115export async function href(site: Site, view: View): Promise<string> {
116  const needsTip = view.kind === "tree" || view.kind === "blob" || view.kind === "history";
117  return canonical(site.page, view, needsTip ? await kindAtTip(site, view.path) : null);
118}