Files
joelhooks__joelclaw/apps/web/lib/remark-obsidian.ts
Joel Hooks 016dccc295 redesign: cool finds index — full titles, readable descriptions, compact cards
- Titles wrap fully instead of truncating
- Relevance text shows complete (no line-clamp)
- Date shown as relative time (2d ago) above title, not stealing width
- Single type badge (repo/article/video) replaces full tag list
- Source URL removed from index (shown on detail page)
- Tighter card spacing for better scan density
- Re-removed duplicate self-hosting discovery (also from Vault source)
2026-02-22 08:07:35 -08:00

126 lines
3.6 KiB
TypeScript

import type { Image, Link, Root, Text } from "mdast";
import { SKIP, visit } from "unist-util-visit";
const WIKILINK_PATTERN = /(!)?\[\[([^[\]]+)\]\]/g;
const IMAGE_EXTENSIONS = new Set([
"png",
"jpg",
"jpeg",
"gif",
"webp",
"svg",
"avif",
]);
function splitTargetAndAlias(value: string): { target: string; alias?: string } {
const pipeIndex = value.indexOf("|");
if (pipeIndex === -1) return { target: value.trim() };
return {
target: value.slice(0, pipeIndex).trim(),
alias: value.slice(pipeIndex + 1).trim(),
};
}
function stripMarkdownExtension(value: string): string {
return value.replace(/\.mdx?$/i, "");
}
function toAdrHref(target: string): string {
const normalized = stripMarkdownExtension(target)
.replace(/\\/g, "/")
.replace(/^\.?\//, "")
.trim();
return `/adrs/${encodeURI(normalized)}`;
}
function toImageHref(target: string): string {
const normalized = stripMarkdownExtension(target)
.replace(/\\/g, "/")
.replace(/^\.?\//, "")
.trim();
return `/images/${encodeURI(normalized)}`;
}
function isImagePath(target: string): boolean {
const ext = target.split(".").pop()?.toLowerCase();
return ext ? IMAGE_EXTENSIONS.has(ext) : false;
}
/**
* Convert Obsidian wikilinks/embeds to standard markdown nodes for MDX.
*
* Examples:
* - ![[adr/foo.jpg|Alt]] -> ![Alt](/images/adr/foo.jpg)
* - [[0077-memory-system-next-phase]] -> [0077-memory-system-next-phase](/adrs/0077-memory-system-next-phase)
* - ![[0077-memory-system-next-phase]] -> [0077-memory-system-next-phase](/adrs/0077-memory-system-next-phase)
*/
export function remarkObsidian() {
return (tree: Root) => {
visit(tree, "text", (node: Text, index, parent) => {
if (!parent || index === undefined) return;
if (parent.type === "link") return;
const source = node.value;
if (!source.includes("[[")) return;
WIKILINK_PATTERN.lastIndex = 0;
let lastIndex = 0;
let matched = false;
let match: RegExpExecArray | null;
const replacementNodes: Array<Text | Link | Image> = [];
while ((match = WIKILINK_PATTERN.exec(source)) !== null) {
const isEmbed = Boolean(match[1]);
const rawInner = match[2];
if (!rawInner) continue;
matched = true;
if (match.index > lastIndex) {
replacementNodes.push({
type: "text",
value: source.slice(lastIndex, match.index),
});
}
const { target, alias } = splitTargetAndAlias(rawInner);
const cleanTarget = stripMarkdownExtension(target).trim();
if (!cleanTarget) {
replacementNodes.push({ type: "text", value: match[0] });
lastIndex = match.index + match[0].length;
continue;
}
if (isEmbed && isImagePath(cleanTarget)) {
replacementNodes.push({
type: "image",
url: toImageHref(cleanTarget),
alt: alias || cleanTarget.split("/").pop() || cleanTarget,
});
} else {
const label = alias || cleanTarget;
replacementNodes.push({
type: "link",
url: toAdrHref(cleanTarget),
children: [{ type: "text", value: label }],
});
}
lastIndex = match.index + match[0].length;
}
if (!matched) return;
if (lastIndex < source.length) {
replacementNodes.push({
type: "text",
value: source.slice(lastIndex),
});
}
parent.children.splice(index, 1, ...replacementNodes);
return [SKIP, index + replacementNodes.length];
});
};
}