Skip to content

Instantly share code, notes, and snippets.

@L4Ph
Last active May 30, 2026 18:52
Show Gist options
  • Select an option

  • Save L4Ph/6cfb4aa6a32cc6ff73272022a59dbfd4 to your computer and use it in GitHub Desktop.

Select an option

Save L4Ph/6cfb4aa6a32cc6ff73272022a59dbfd4 to your computer and use it in GitHub Desktop.
remark plugin
import { visit } from "unist-util-visit";
import type { Root, Text, HTML, Link, PhrasingContent } from "mdast";
const extractYouTubeVideoID = (url: string): string | undefined => {
try {
const { hostname, pathname, searchParams } = new URL(url);
if (/(^|\.)youtube\.com$/.test(hostname)) {
if (pathname === "/watch") {
return searchParams.get("v") ?? undefined;
}
const pathMatch = pathname.match(/^\/(embed|shorts)\/([^/?]+)/);
if (pathMatch) return pathMatch[2];
}
if (hostname === "youtu.be") {
return pathname.slice(1).split("?")[0];
}
if (
hostname === "googleusercontent.com" &&
pathname.startsWith("/youtube.com/")
) {
const segments = pathname.split("/");
return segments.pop() || segments[segments.length - 1];
}
} catch (e) {
console.error("URL parse error:", url, e);
}
return undefined;
};
const transformTextNode = (node: Text): PhrasingContent[] => {
const YOUTUBE_SYNTAX_REGEX = /@\[youtube\]\(([^)]+?)\)/g;
const newNodes: PhrasingContent[] = [];
let lastIndex = 0;
let hasMatch = false;
// biome-ignore lint/suspicious/noAssignInExpressions: <explanation>
// biome-ignore lint/suspicious/noImplicitAnyLet: <explanation>
for (let match; (match = YOUTUBE_SYNTAX_REGEX.exec(node.value)) !== null; ) {
hasMatch = true;
const [fullMatch, url] = match;
if (match.index > lastIndex) {
newNodes.push({
type: "text",
value: node.value.slice(lastIndex, match.index),
});
}
const videoID = extractYouTubeVideoID(url);
if (videoID) {
newNodes.push({
type: "html",
value: `<lite-youtube videoid="${videoID}" autoLoad></lite-youtube>`,
});
} else {
newNodes.push({ type: "text", value: fullMatch });
}
lastIndex = YOUTUBE_SYNTAX_REGEX.lastIndex;
}
if (hasMatch) {
if (lastIndex < node.value.length) {
newNodes.push({
type: "text",
value: node.value.slice(lastIndex),
});
}
return newNodes;
}
return [node];
};
const handleConsecutivePattern = (
nodes: PhrasingContent[],
index: number,
): [PhrasingContent | null, number] => {
const current = nodes[index];
const next = nodes[index + 1];
if (
current.type === "text" &&
current.value === "@" &&
next?.type === "link" &&
next.children.length === 1 &&
next.children[0].type === "text" &&
next.children[0].value === "youtube"
) {
const videoID = extractYouTubeVideoID(next.url);
if (videoID) {
return [
{
type: "html",
value: `<lite-youtube videoid="${videoID}"><a href="https://www.youtube.com/watch?v=${videoID}"></a></lite-youtube>`,
},
2,
];
}
}
return [null, 1];
};
export default function remarkLiteYouTube() {
return (tree: Root) => {
visit(tree, "paragraph", (paragraphNode) => {
const newChildren: PhrasingContent[] = [];
let index = 0;
while (index < paragraphNode.children.length) {
const child = paragraphNode.children[index];
if (index + 1 < paragraphNode.children.length) {
const [transformed, consumed] = handleConsecutivePattern(
paragraphNode.children,
index,
);
if (transformed) {
newChildren.push(transformed);
index += consumed;
continue;
}
}
if (child.type === "text") {
const transformed = transformTextNode(child);
newChildren.push(...transformed);
index++;
continue;
}
newChildren.push(child);
index++;
}
paragraphNode.children = newChildren;
});
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment