FreshでObsidianのウィキリンクをパースする

作成日
更新日

前提

やること

  • 記事のパース
    • サーバーサイドでやっておくべき
      • GPTにはクライアントサイド(api)でやれって言われたけど無視する
    • ウィキリンク
    • 画像
    • 外部リンクには特殊なクラスをつけたい external
  • [[ウィキリンク]]の形をパースする
  • [[ウィキリンク|表示名]] の形をパースする
  • [[Notes/ウィキリンク]]
    • ウィキリンク にパース
  • [[Notes/ウィキリンク|表示名]]
    • これは 表示名 にパース
  • リンク先が存在するかどうかを確認する必要がある
    • もしない場合には 別名 の名前のみを表示するか <span class="wikilink">ウィキリンク</a> みたいにノートがあることが外からわかるようにしておくか。
    • 存在する場合はその記事へのリンクを付与する。また internal クラスをつける
  • ![[画像名.png]] の形をパースする
  • ![[画像名.webp|548x225]] の形をパースする
    • Obsidian Image Converterでalignを決めていても特にソースコード上では見える形ではでてこない。
  • ![](画像名.webp)
    • この書き方では |500x200 のサイズ指定は[]で行う
  • ![画像の説明](画像名.webp)
    • caption がつけられる
  • ![画像の説明|95x95](画像名.jpeg)
  • ![|95x95](画像名.jpeg)
    • alt なしバージョン

画像についての調査

FreshでObsidianのウィキリンクをパースする-1742518627193

|78x78

アイス|78x78

アイス|78x78 |78x78

パースする

ObsidianRenderer をつくる

utils/obsidianRenderer
import { Renderer } from "@deno/gfm";
import he from "npm:he@^1.2"; // エンコードライブラリ
import { PostCacheEntry } from "../types/postCacheEntry.ts";

export class ObsidianRenderer extends Renderer {
  private postCache = new Map<string, PostCacheEntry>();

  constructor(cache?: Map<string, PostCacheEntry>) {
    super();
    if (cache) {
      this.postCache = cache;
    }
  }

  override text(text: string): string {
    // Obsidian画像構文: ![[filename.webp|300x200]]
    text = text.replace(/\!\[\[([^\]|]+)(?:\|(\d+)x(\d+))?\]\]/g, (_match, filename: string, width?: string, height?: string) => {
      const ext = filename.split(".").pop()?.toLowerCase();
      const isMedia = ["mp4", "webm", "pdf"].includes(ext ?? "");
  
      if (isMedia) {
        return `<iframe src="/media/${filename}" width="100%" height="400" class="media-embed" title="${he.encode(filename)}"></iframe>`;
      }
  
      const imgSrc = `/api/getImage/${he.encode(filename)}`;
      const sizeAttr = (width && height) ? ` width="${width}" height="${height}"` : "";
      return `<a href="/images/${he.encode(filename)}"><img src="${imgSrc}" alt="${he.encode(filename)}"${sizeAttr} /></a>`;
    });
  
    // 通常の [[wikilink|label]] 構文(!無し)
    text = text.replace(/(?<!\!)\[\[([^\]]+?)\]\]/g, (_match, content: string) => {
      const [rawSlug, display] = content.split("|");
      const slug = rawSlug.replace(/^.+\//, "");
      const label = display || slug;
      const entry = this.postCache.get(slug);
  
      if (entry?.exists) {
        return `<a href="/${entry.category}/${slug}" class="internal">${he.encode(label)}</a>`;
      } else {
        return `<span class="wikilink">${he.encode(label)}</span>`;
      }
    });
  
    return text;
  }
  

  override image(src: string, title: string | null, alt: string): string {
    // Obsidian形式: ![[image.webp|548x225]]
    let file = src;
    let width: string | undefined;
    let height: string | undefined;

    const match = src.match(/^(.+?)\|(\d+)x(\d+)$/);
    if (match) {
      [, file, width, height] = match;
    }

    const path = file.startsWith("http") ? file : `/images/${file}`;
    const sizeAttr = width && height ? ` width="${width}" height="${height}"` : "";
    const altAttr = file;

    return `<img src="${path}" alt="${he.encode(altAttr)}"${sizeAttr}${title ? ` title="${he.encode(title)}"` : ""} />`;
  }

  override link(href: string, title: string | null, text: string): string {
    const isExternal = /^https?:\/\//.test(href);
    const classAttr = isExternal ? ' class="external"' : ' class="internal"';
    const targetAttr = isExternal ? ' target="_blank" rel="noopener"' : '';
    const titleAttr = title ? ` title="${he.encode(title)}"` : "";

    return `<a href="${href}"${classAttr}${targetAttr}${titleAttr}>${he.encode(text)}</a>`;
  }
}

この Renderer を次のように使う。

libs/getPosts.ts
  // Markdown から全 [[slug]] を抽出
  const slugs = Array.from(
    new Set(
      [...data.content.matchAll(/\[\[([^\]|]+)(?:\|.*?)?\]\]/g)].map((m) =>
        m[1].replace(/^.+\//, "")
      ),
    ),
  );

  // Supabase からウィキリンク上の記事が存在するかをチェック
  const postMap = await getPostCache(slugs);

  // MyRenderer に渡す
  const renderer = new ObsidianRenderer(postMap);
  // このとき allowedTags と allowedAttributes と allowedClassesを
  // 書いておかないと消されちゃう
  const html = await render(data.content, {
    renderer: renderer,
    allowIframes: true,
    allowMath: true,
    allowedTags: ["img", "video", "iframe", "figure", "figcaption", "math", "a"],
    allowedAttributes: {
      a: ["href", "class", "target", "rel", "title"],
      img: ["src", "alt", "width", "height", "title"],
      iframe: ["src", "width", "height"],
    },
    allowedClasses: {
      a: ["external", "internal"],
      div: ["highlight", "notranslate"],
    },
  });

Renderer内は同期的に処理するので、Supabase からウィキリンク上の記事が存在するかをチェックする処理は外部で行ってからrendererに渡す必要がある。

utils/postExists.ts
import { PostCacheEntry } from "../types/postCacheEntry.ts";
import { supabase } from "./supabase.ts";

/**
 * Supabase 上に指定された slug を title に含む記事が存在するかチェック
 * @param slug[] 検索したい部分文字列の配列(例: "Obsidian")
 * @returns 存在すれば PostCacheEntryをMapで返す
 */
export async function getPostCache(slugs: string[]): Promise<Map<string, PostCacheEntry>> {
  const { data, error } = await supabase
    .from("articles")
    .select("title, category")
    .eq("private", false);

  const result = new Map<string, PostCacheEntry>();

  if (error || !data) return result;

  for (const slug of slugs) {
    const matched = data.find((row) => row.title === slug);
    if (matched) {
      result.set(slug, {
        exists: true,
        category: matched.category.name || "notes", // fallback
      });
    } else {
      result.set(slug, {
        exists: false,
        category: "",
      });
    }
  }

  return result;
}

とりあえずコレでパースできた。 追加であるかもだけど。

サイトアイコン
公開日
更新日