Vectrum
Sign In
JjohannSoAdd wikisHistory
81 lines · 3.7 KB · TypeScript
import type { Nodes, PhrasingContent, Root } from "mdast";
import { encodePath } from "./format";

// Wiki page names and links, shared by the server renderer and the editor's preview (so no node:path).

/** "Getting Started" → "Getting-Started", the page's file and URL name. */
export const pageSlug = (title: string) => title.trim().replace(/\s+/g, "-");

/** "Getting-Started" → "Getting Started". */
export const pageTitle = (slug: string) => slug.replace(/-/g, " ");

/** A page's URL, under the wiki's base (e.g. /alice/app/wiki). */
export const pageHref = (wiki: string, slug: string) => (slug === "Home" ? wiki : `${wiki}/${encodeURIComponent(slug)}`);

const PAGE_EXT = /\.(md|markdown|mdown|mkdn|mkd)$/i;
const IMAGE_EXT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)$/i;

/** "a/./b/../c.png" → "a/c.png", never leaving the root. */
function normalize(target: string) {
  const parts: string[] = [];
  for (const part of target.split("/")) {
    if (part === "..") parts.pop();
    else if (part && part !== ".") parts.push(part);
  }
  return parts.join("/");
}

const basename = (file: string) => file.slice(file.lastIndexOf("/") + 1);

/**
 * Where a relative URL in a page points. Page URLs are flat (/wiki/<name>, wherever the file is),
 * so links resolve against the wiki's root: "Other-Page" and "docs/Other-Page.md" to that page,
 * images to their content in the wiki's repository, and other files to /wiki/<path>, which serves
 * them when no page has that name.
 */
export function wikiUrl(wiki: string, url: string, key: string) {
  const [target, hash] = url.split("#");
  const resolved = normalize(target);
  const suffix = hash ? `#${hash}` : "";
  if (key === "src") return `${wiki}/_raw/${encodePath(resolved)}${suffix}`;
  if (PAGE_EXT.test(resolved)) return `${pageHref(wiki, pageSlug(basename(resolved).replace(PAGE_EXT, "")))}${suffix}`;
  return `${wiki}/${encodePath(resolved)}${suffix}`;
}

/** [[Page]], [[Text|Page]], [[Page#Section]] and [[image.png]]. */
const WIKI_LINK = /\[\[([^[\]\n]+?)\]\]/g;

function linkNode(wiki: string, inner: string): PhrasingContent {
  const bar = inner.indexOf("|");
  const text = (bar < 0 ? inner : inner.slice(0, bar)).trim();
  const target = (bar < 0 ? inner : inner.slice(bar + 1)).trim();
  if (/^[a-z][a-z0-9+.-]*:/i.test(target)) return { type: "link", url: target, children: [{ type: "text", value: text }] };
  if (IMAGE_EXT.test(target)) return { type: "image", url: wikiUrl(wiki, target, "src"), alt: bar < 0 ? basename(target) : text };
  const [page, hash] = target.split("#");
  const url = page ? `${pageHref(wiki, pageSlug(page))}${hash ? `#${hash}` : ""}` : `#${hash ?? ""}`;
  return { type: "link", url, children: [{ type: "text", value: text }] };
}

function split(wiki: string, value: string): PhrasingContent[] {
  const out: PhrasingContent[] = [];
  let last = 0;
  for (const m of value.matchAll(WIKI_LINK)) {
    if (m.index > last) out.push({ type: "text", value: value.slice(last, m.index) });
    out.push(linkNode(wiki, m[1]));
    last = m.index + m[0].length;
  }
  if (last < value.length) out.push({ type: "text", value: value.slice(last) });
  return out;
}

function visit(wiki: string, node: Nodes) {
  // Links can't hold links; code keeps its text as it is (it isn't made of text nodes anyway).
  if (!("children" in node) || node.type === "link" || node.type === "linkReference") return;
  const children = node.children as Nodes[];
  node.children = children.flatMap((child) => (child.type === "text" && child.value.includes("[[") ? split(wiki, child.value) : (visit(wiki, child), [child]))) as never;
}

/** A remark plugin turning wiki links into links to pages under `base`. */
export function remarkWikiLinks({ base }: { base: string }) {
  return (tree: Root) => visit(base, tree);
}