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);
}