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