Webux Lab

By Studio Webux

Typescript, Markdown, Tauri, Browser

TG
Tommy Gingras Studio Webux 2023-06-30

Markdown Library for the Browser; Marked !

Here is how I use the npm package marked to convert my markdown files to HTML.

This snippet is from my new project, I’m using Vue 3 and Tauri.

Code

markdown.ts

import { marked } from "marked";
import { markedHighlight } from "marked-highlight";
import hljs from "highlight.js";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import { documentDir, sep } from "@tauri-apps/api/path";
import { convertImages } from "./image";

export async function markdown(filepath: string, content: string) {
  const localContent = (await convertImages(
    filepath.split(sep).slice(0, -1).join(sep),
    content
  )) as string;

  const renderer = {
    list: (body: string, ordered: boolean, start: number) => {
      return `<ul class="list-disc list-inside leading-4">${body}</ul>`;
    },
    link: (href: string, title: string, text: string) => {
      return `<a class="link" href="${href}" alt=${title} target="_blank">${text}</a>`;
    },
    html(html: string, block: boolean) {
      return html;
    },
    image(href: string, title: string, text: string) {
      return `<img src="${convertFileSrc(documentDirPath)}${encodeURIComponent(
        sep
      )}${href}" alt="${title}">`;
    },
    table(header: string, body: string) {
      return `<table class="table"><thead>${header}</thead><tbody>${body}</tbody></table>`;
    },
  };

  const documentDirPath = await documentDir();
  marked.use(
    markedHighlight({
      async: true,
      langPrefix: "hljs language-",
      highlight(code, lang) {
        const language = hljs.getLanguage(lang) ? lang : "plaintext";
        return hljs.highlight(code, { language }).value;
      },
    })
  );

  marked.use({ renderer });
  return marked.parse(localContent, {
    mangle: false,
    headerIds: false,
  });
}

image.ts

import { documentDir, join, sep } from "@tauri-apps/api/path";
import { convertFileSrc } from "@tauri-apps/api/tauri";
import {
  imageDataOnlyRe,
  imageLocalRe,
  imageMarkdownRe,
  imageSrcRe,
} from "../Regexes/Images";
import { base64ToArrayBuffer } from "./utils";

export async function convertImages(
  filepath: string,
  content: string
): Promise<string> {
  const newContent: string[] = [];
  const documentDirPath = await documentDir();

  for await (let line of content.split("\n")) {
    const rule1 = imageMarkdownRe;
    const rule2 = imageLocalRe;
    const match1 = rule1.exec(line);
    const match2 = rule2.exec(line);
    const alt = rule2.exec(line)?.[2];
    const width = rule2.exec(line)?.[3];
    let data: any;

    try {
      if (line !== "" && (match1 || match2)) {
        if (match1)
          data = await fetch(`${convertFileSrc(documentDirPath)}${match1[2]}`);
        if (match2) {
          if (match2[1].startsWith("."))
            data = await fetch(convertFileSrc(await join(filepath, match2[1])));
          else
            data = await fetch(
              `${convertFileSrc(documentDirPath)}${match2[1]}`
            );
        }

        const blob = await data.blob();
        const buffer = await blob.arrayBuffer();

        const base64 = btoa(
          new Uint8Array(buffer).reduce(
            (data, byte) => data + String.fromCharCode(byte),
            ""
          )
        );
        let extension = blob.type;
        newContent.push(
          `<img src="data:${extension};base64,${base64}" alt="${alt}" width="${width}" class="mx-auto">`
        );
      } else {
        newContent.push(line);
      }
    } catch {
      newContent.push(line);
    }
  }

  return newContent.join("\n");
}

export async function extractURLOrData(
  url: string
): Promise<{ type: string | undefined; buffer: ArrayBuffer }> {
  const u = url.replace(imageSrcRe, "$1");

  if (!u) throw new Error("Invalid image url, nothing to do");

  if (u.startsWith("data:")) {
    const type: string | undefined = u?.match(imageDataOnlyRe)?.[1];
    const base64: string | undefined = u?.match(imageDataOnlyRe)?.[2];
    if (!base64)
      throw new Error("unable to extract the base64 content from the image");
    return { type, buffer: base64ToArrayBuffer(base64) };
  }
  if (u.startsWith("blob:")) {
    const data = await fetch(u);
    if (!data.body)
      throw new Error("Something went wrong while fetching the image content");

    const blob = await data.blob();
    const buffer = await blob.arrayBuffer();
    const type = blob.type;

    return { type: type.split("/")[1], buffer };
  }
  if (u.startsWith("http:") || u.startsWith("https:")) {
    console.log("http or https", u);
    const data = await fetch(u);
    if (!data.body)
      throw new Error("Something went wrong while fetching the image content");

    const blob = await data.blob();
    const buffer = await blob.arrayBuffer();
    const type = blob.type;

    console.log(type, buffer);

    return { type: type.split("/")[1], buffer };
  }

  throw new Error("Url format not implemented.");
}

The ./utils code is available: https://webuxlab.com/en/web-projects/typescript-helper-functions The ../Regexes/Images code is available: https://webuxlab.com/en/web-projects/javascript-regexes


Usage (With Vue 3)

I do not sanitize the input because in this specific project I control the content.
 You must implement some kind of sanitization when using with public data.

Markdown.vue

<script setup lang="ts">
import { onMounted, Ref, ref } from "vue";

import {
  NotificationType,
  useNotificationStore,
} from "../../stores/notification";

import { markdown } from "../../libs/markdown";

const notificationStore = useNotificationStore();

const props = defineProps({
  content: { type: String, required: true },
  filepath: { type: String, required: true },
});

const html: Ref<string> = ref("");

onMounted(async () => {
  try {
    html.value = await markdown(props.filepath, props.content);
  } catch (e: any) {
    notificationStore.showNotification(NotificationType.ERROR, e.message);
  }
});
</script>

<template>
  <article class="prose lg:prose-xl" v-html="html"></article>
</template>

Search