Open41

Denoでmarkdown処理の練習

kawarimidollkawarimidoll
  • ひとつの.mdファイルを受け取ってWebサイトを生成
  • Navbar/content/footerの3ブロックのページ
  • 構造に沿ったフォルダ構成
  • ナビゲーションリンクは有効なhrefを保持
  • 自動生成CSS
  • SVGファビコン
kawarimidollkawarimidoll

単一の.mdファイルをソースとし、deno run main.ts src.md ./dstみたいな形で実行する
ということでCLI引数の扱いを作っていこう

main.ts
const [filename, buildPath = "./build"] = Deno.args;

console.log(filename, buildPath);

if (!filename || !filename.endsWith(".md")) {
  console.log("Please specify .md file as a source!");
  Deno.exit(1);
} else {
  console.log(`Building site with '${filename}' into '${buildPath}'`);
}

これでdeno run main.tsだけだと怒られて、一つ以上引数を指定すると通るようになる

kawarimidollkawarimidoll

依存関係のインポート、型とグローバル変数の定義

main.ts
// deno-lint-ignore-file no-unused-vars
import { Marked } from "https://deno.land/x/markdown@v2.0.0/mod.ts";
import { ensureFileSync } from "https://deno.land/std@0.102.0/fs/mod.ts";

interface Page {
  path: string; // webページURL
  name: string; // ページタイトル
  html: string; // ページの完全HTML
}

interface Layout {
  // deno-lint-ignore no-explicit-any
  [key: string]: any;
}

const pages: Page[] = [];
const layout: Layout = {};

deno lintのエラー防止のためいくつかdeno-lint-ignoreを追加している

kawarimidollkawarimidoll

では機能を追加していく
まずはファイルの読み込み

読み込む.mdファイルのルールは以下のようにする

  • 先頭に---で囲んだYAML Front Matterを配置し、タイトル(必須)、スタイル(任意)、絵文字ファビコン(任意)を定義
  • 単一ファイル内に複数ページを入れるため、+++で分割
    • その下に/[path]:[title]形式でページのパスとタイトルを定義
    • layout:[name]形式でレイアウト要素を定義
  • 自動で/home:Homeをホームページとする
example.md
---
title: Deno Markdown Site
styles: >
  body { color: #22a6b3; }
favicon: 🦕
---
/home:Home

# Home

Hello world!

+++
/about:About

# About

Built for learning.

+++
layout:footer

deno-md-site

これを読み込んで使用する

main.ts
const COMPONENT_DELIMITER = "+++";
const content = await Deno.readTextFile(filename);
const components = content.split(COMPONENT_DELIMITER);
console.log(components);

実行!

❯ deno run --allow-read main.ts example.md
example.md ./build
Building site with 'example.md' into './build'
[
  "---\ntitle: Deno Markdown Site\nstyles: >\nbody { color: #22a6b3; }\nfavicon: 🦕\n---\n/home:Home\n\n# Home\n...",
  "\n/about:About\n\n# About\n\nBuilt for learning.\n\n",
  "\nlayout:footer\n\ndeno-md-site\n"
]

項目を読み込んで分割して表示できた

kawarimidollkawarimidoll

Markedを使ってデータを取り出してみよう

main.ts
const { meta: frontMatter } = Marked.parse(components[0]);
const { title, styles, favicon } = frontMatter;
console.log({ title, styles, favicon });
❯ deno run --allow-read main.ts example.md
example.md ./build
Building site with 'example.md' into './build'
[
  "---\ntitle: Deno Markdown Site\nstyles: >\n  body { color: #22a6b3; }\nfavicon: 🦕\n---\n/home:Home\n\n# Hom...",
  "\n/about:About\n\n# About\n\nBuilt for learning.\n\n",
  "\nlayout:footer\n\ndeno-md-site\n"
]
{ title: "Deno Markdown Site", styles: "body { color: #22a6b3; }\n", favicon: "🦕" }

OK 続いてHTML本体の抽出に入る

kawarimidollkawarimidoll

各コンポーネントのパースを追加

main.ts
components.forEach((component) => {
  const { content } = Marked.parse(component);
  console.log(content);
});

以下の結果が表示される

<p>/home:Home</p>
<h1 id="home">Home</h1>
<p>Hello world!</p>

<p>/about:About</p>
<h1 id="about">About</h1>
<p>Built for learning.</p>

<p>layout:footer</p>
<p>deno-md-site</p>

最初の<p>タグの中にページのメタデータが入っているので抽出しよう
(これはファイル分けてfront matterに入れたら良いのでは…)

kawarimidollkawarimidoll

String.prototype.match()で抽出する
match()の出力はRegExp String Iteratorで返ってくるので分割代入でガッと取得する

main.ts
const COMPONENT_TYPE_REGEXP = /<\S>(.*?)\:(.*?)<\/\S>/g;
components.forEach((component) => {
  const { content } = Marked.parse(component);

  const [[, path, name]] = content.matchAll(COMPONENT_TYPE_REGEXP);
  console.log({ path, name });
});

表示させるとこんな感じ

{ path: "/home", name: "Home" }
{ path: "/about", name: "About" }
{ path: "layout", name: "footer" }
kawarimidollkawarimidoll

1行目に入ってくることがわかってるのにこういう抽出をするのはちょっと気に食わないな
自分なりに変えよう

main.ts
components.forEach((component) => {
  const { content } = Marked.parse(component);
  const match = content.match(/([^\n]*)\n(.*)/s) ?? [];
  const [path, name] = match[1].replace(/<[^>]+>/g, "").split(":");
  const html = match[2].replace(/\n/g, "");
  console.log({ path, name, html });
});

こんな感じかな

{ path: "/home", name: "Home", html: '<h1 id="home">Home</h1><p>Hello world!</p>' }
{
  path: "/about",
  name: "About",
  html: '<h1 id="about">About</h1><p>Built for learning.</p>'
}
{ path: "layout", name: "footer", html: "<p>deno-md-site</p>" }
kawarimidollkawarimidoll

で、これをpath"layout"かで分岐してグローバル変数に追加していく

main.ts
components.forEach((component) => {
  const { content } = Marked.parse(component);

  const match = content.match(/([^\n]*)\n(.*)/s) ?? [];
  const [path, name] = match[1].replace(/<[^>]+>/g, "").split(":");
  const html = match[2].replace(/\n/g, "");

  if (path === "layout") {
    layout[name] = html;
  } else {
    pages.push({ path, name, html });
  }
});

console.log({ pages, layout });

こういう出力が得られる

{
  pages: [
    { path: "/home", name: "Home", html: '<h1 id="home">Home</h1><p>Hello world!</p>' },
    {
      path: "/about",
      name: "About",
      html: '<h1 id="about">About</h1><p>Built for learning.</p>'
    }
  ],
  layout: { footer: "<p>deno-md-site</p>" }
}
kawarimidollkawarimidoll

HTMLとして仕上げるためのをテンプレートとヘルパーを作る

ルートディレクトリにTOPページとスタイルシートがあり、他のページはディレクトリ内に入っているという想定でヘルパーを作成

main.ts
const HOME_PATH = "/home";
const STYLESHEET_PATH = "styles.css";
const isHomePath = (path: string) => path === HOME_PATH;
const getStylesheetHref = (path: string) =>
  (isHomePath(path) ? "" : "../") + STYLESHEET_PATH;
const getFaviconSvg = (favicon: string) =>
  `<svg xmlns="http://www.w3.org/2000/svg"><text y="32" font-size="32">${favicon ||
    "🦕"}</text></svg>`;
kawarimidollkawarimidoll

続いてナビゲーションバーと全体のテンプレートを作成

main.ts
const getNavigation = (currentPath: string) =>
  `<div id="nav">${
    pages.map(({ path, name }) => {
      const href = path === HOME_PATH ? "/" : path;
      const isSelectedPage = path === currentPath;
      const classes = `nav-item ${isSelectedPage ? "selected" : ""}`;
      return `<a class="${classes}" href=${href}>${name}</a>`;
    }).join("")
  }</div>`;
const footer = layout.footer ? `<div id="footer">${layout.footer}</div>` : "";
const getHtmlByPage = ({ path, name, html }: Page) => `
<!DOCTYPE html>
<html>
  <head>
    <title>${name} | ${title}</title>
    <link rel="stylesheet" href="${getStylesheetHref(path)}">
    <link rel="icon" href="/favicon.svg">
  </head>
  <body>
    <div id="title">
      ${title}
    </div>
    ${getNavigation(path)}
    <div id="main">
      ${html}
    </div>
    ${footer}
  </body>
</html>`;

ビルド結果に改行は不要だろうという過激派なので参考元で"\n"を入れているところもガンガン省いている

kawarimidollkawarimidoll

そしたらビルドを行う
pagesは以下のようなデータが入っているので、pathを名前とするディレクトリにindex.htmlを作成する

[
  { path: "/home", name: "Home", html: '<h1 id="home">Home</h1><p>Hello world!</p>' },
  {
    path: "/about",
    name: "About",
    html: '<h1 id="about">About</h1><p>Built for learning.</p>'
  }
]

これを行うにはpagesに関してループし、ensureFileSyncでディレクトリを作成し、Deno.writeTextFileSync()で書き出すという方法を取る

main.ts
pages.forEach((page) => {
  const { path } = page;
  const outputDir = isHomePath(path) ? "" : path;
  const outputPath = buildPath + outputDir + "/index.html";
  ensureFileSync(outputPath);
  Deno.writeTextFileSync(outputPath, getHtmlByPage(page));
});

ensureFileSyncの実行に--unstableフラグが、writeTextFileSyncの実行に--allow-writeフラグが必要

❯ deno run --allow-read --allow-write --unstable main.ts example.md
Building site with 'example.md' into './build'

❯ denotree build
build
├── about/
│  └── index.html
└── index.html

buildディレクトリの中にhtmlファイルが生成されている

kawarimidollkawarimidoll

Assetsの書き出しも追加 これは/assets内に入れても良いかもなあ

main.ts
Deno.writeTextFileSync(`${buildPath}/styles.css`, styles || '');
Deno.writeTextFileSync(`${buildPath}/favicon.svg`, getFaviconSvg(favicon));

これで表示する項目は完成なので適当なサーバープログラムを使えば表示できる

ファイル構成はこんな感じ

❯ denotree -u
.
├── build/
│  ├── about/
│  │  └── index.html
│  ├── favicon.svg
│  ├── index.html
│  └── styles.css
├── deploy.d.ts
├── deps.ts
├── example.md
├── LICENSE
├── main.ts
├── README.md
├── server.ts
└── velociraptor.yml

けどせっかくだからdeployctl使いたいよね
https://deno.com/deploy/docs/serve-static-assets

server.ts
/// <reference path="./deploy.d.ts" />

async function handleRequest(request: Request) {
  const { pathname } = new URL(request.url);

  const filename = "./build" + (pathname === "/" ? "" : pathname) +
    (pathname.includes(".") ? "" : "/index.html");

  const file = new URL(filename, import.meta.url);
  console.log(file);
  const type = pathname.endsWith(".css")
    ? "text/css"
    : pathname.endsWith(".svg")
    ? "image/xml+svg"
    : "text/html";

  const content = await fetch(file);
  return new Response(
    await content.text(),
    {
      headers: {
        "content-type": `${type}; charset=utf-8`,
      },
    },
  );
}

addEventListener("fetch", (event: FetchEvent) => {
  event.respondWith(handleRequest(event.request));
});

起動成功!良いやん!

kawarimidollkawarimidoll

kymime使って少し綺麗に

server.ts
/// <reference path="./deploy.d.ts" />

import mime from "https://cdn.skypack.dev/mime@2.5.2/lite?dts";
import ky from "https://cdn.skypack.dev/ky@0.28.5?dts";

async function handleRequest(request: Request) {
  const { pathname } = new URL(request.url);

  const filename = "./build" + (pathname === "/" ? "" : pathname) +
    (pathname.includes(".") ? "" : "/index.html");

  const file = new URL(filename, import.meta.url);
  console.log(file);

  const ext = file.href.replace(/^.*\.([^.]+)$/, "$1");
  const type = mime.getType(ext);
  console.log({ pathname, ext, type });

  return new Response(
    await ky(file).text(),
    {
      headers: {
        "content-type": `${type}; charset=utf-8`,
      },
    },
  );
}

addEventListener("fetch", (event: FetchEvent) => {
  event.respondWith(handleRequest(event.request));
});

filenameを作るところがちょっとかっこ悪いなあ

kawarimidollkawarimidoll
  • 単一ファイルじゃなくて複数ファイルの構造を尊重したまま静的ディレクトリを生成したい
  • そのへんの設定はCLI引数じゃなくてconfig.tsから読み込みたい deploy環境だとfsが無いのでconfig.jsonとかは無理でしょ多分
  • リンクとか画像とかは問題なさそうだ
kawarimidollkawarimidoll

では複数ファイルから静的サイトを作ってみよう
上で使っていたexample.mdindex.mdabout.mdlayouts/footer.mdに分割して、以下のようなファイル構造にする(必要なファイルのみ抜粋)

.
├── config.ts
├── deps.ts
├── docs/
│  ├── about.md
│  ├── index.md
│  └── layouts/
│    └── footer.md
├── main.ts
└── server.ts

これでまずはdocsディレクトリ内を表示する

config.ts
export const SOURCE_DIR = "docs";
export const BUILD_DIR = "build";
main.ts
import { BUILD_DIR, SOURCE_DIR } from "./config.ts";
import { existsSync, walkSync } from "https://deno.land/std@0.102.0/fs/mod.ts";

if (!existsSync(SOURCE_DIR)) {
  console.log(`SOURCE_DIR: '${SOURCE_DIR}' is not exists!`);
  Deno.exit(1);
} else {
  console.log(`Building site with '${SOURCE_DIR}' into '${BUILD_DIR}'`);
}

for (const entry of walkSync(SOURCE_DIR)) {
  if (!entry.isFile) {
    continue;
  }
  console.log(entry);
}

実行結果

{
  path: "docs/layouts/footer.md",
  name: "footer.md",
  isFile: true,
  isDirectory: false,
  isSymlink: false
}
{
  path: "docs/index.md",
  name: "index.md",
  isFile: true,
  isDirectory: false,
  isSymlink: false
}
{
  path: "docs/about.md",
  name: "about.md",
  isFile: true,
  isDirectory: false,
  isSymlink: false
}

OK、各ファイルを確認できている

kawarimidollkawarimidoll

各ファイルはfrontmatterと内容を持つ

index.md
---
styles: >
  body { color: #22a6b3; }
---

# Home

Hello world!
about.md
---
title: About this Site
styles: >
  body { color: #733aff; }
favicon: 🥳
---

# About

Built for learning.

![pikachu](https://media.giphy.com/media/xuXzcHMkuwvf2/giphy.gif)
layouts/footer.md
deno-md-site

by [kawarimidoll](https://github.com/kawarimidoll) made with ❤️

config.tsにデフォルト値を追加

config.ts
  export const SOURCE_DIR = "docs";
  export const BUILD_DIR = "build";
+ export const SITE_NAME = "Deno SSG site";
+ export const FAVICON = "🦕";
kawarimidollkawarimidoll

ページごとにパースして表示してみよう

main.ts
for (const entry of walkSync(SOURCE_DIR)) {
  if (!entry.isFile || !entry.name.endsWith(".md")) {
    continue;
  }

  const markdown = Deno.readTextFileSync(entry.path);
  const { meta, content } = Marked.parse(markdown);
  console.log(meta);
  console.log(content);
}
{}
<p>deno-md-site</p>
<p>by <a href="https://github.com/kawarimidoll">kawarimidoll</a> made with ❤️</p>

{ styles: "body { color: #22a6b3; }\n", favicon: "🦕" }
<h1 id="home">Home</h1>
<p>Hello world!</p>

<pre><code>echo &#39;Hello world!&#39;
</code></pre>

{ title: "About this Site", styles: "body { color: #733aff; }\n", favicon: "🥳" }
<h1 id="about">About</h1>
<p>Built for learning.</p>
<p><img src="https://media.giphy.com/media/xuXzcHMkuwvf2/giphy.gif" alt="pikachu"></p>

良いでしょう

kawarimidollkawarimidoll

書き出しパスを作っていく

main.ts
import { relative } from "https://deno.land/std@0.102.0/path/mod.ts";

for (const entry of walkSync(SOURCE_DIR)) {
  if (!entry.isFile || !entry.name.endsWith(".md")) {
    continue;
  }

  const markdown = Deno.readTextFileSync(entry.path);
  const { meta } = Marked.parse(markdown);
  const relativePath = relative(SOURCE_DIR, entry.path);
  const path = entry.name == "index.md"
    ? relativePath.replace(/\.md$/, ".html")
    : relativePath.replace(/\.md$/, "/index.html");
  console.log(relativePath, "->", path);
}
layouts/footer.md -> layouts/footer/index.html
index.md -> index.html
about.md -> about/index.html

DRYじゃないけどリファクタリングはあとに回そう

kawarimidollkawarimidoll

ディレクトリに応じpageslayoutへ登録していく

main.ts
import { extname } from "https://deno.land/std@0.102.0/path/mod.ts";

for (const entry of walkSync(SOURCE_DIR)) {
  if (!entry.isFile || !entry.name.endsWith(".md")) {
    continue;
  }
  const ext = extname(entry.name);
  const filename = entry.name.replace(ext, "");

  const markdown = Deno.readTextFileSync(entry.path);
  const { meta, content } = Marked.parse(markdown);
  const name = (meta.title ? `${meta.title} | ` : "") + SITE_NAME;
  const relativePath = relative(SOURCE_DIR, entry.path);
  const path = entry.name == "index.md"
    ? relativePath.replace(/\.md$/, ".html")
    : relativePath.replace(/\.md$/, "/index.html");
  console.log(relativePath, "->", path);
  const html = content;

  if (relativePath.startsWith("layouts")) {
    layout[filename] = html;
  } else {
    pages.push({ path, name, html });
  }
}

console.log({ pages, layout });
{
  pages: [
    {
      path: "index.html",
      name: "Deno SSG site",
      html: '<h1 id="home">Home</h1>\n<p>Hello world!</p>\n\n<pre><code>echo &#39;Hello world!&#39;\n</code></pre>\n'
    },
    {
      path: "about/index.html",
      name: "About this Site | Deno SSG site",
      html: '<h1 id="about">About</h1>\n<p>Built for learning.</p>\n<p><img src="https://media.giphy.com/media/xuXz...'
    }
  ],
  layout: {
    footer: '<p>deno-md-site</p>\n<p>by <a href="https://github.com/kawarimidoll">kawarimidoll</a> made with ❤️</p...'
  }
}

これでチュートリアルとほぼ同じ結果になったかな

kawarimidollkawarimidoll

pathの解釈が違ったので少し修正
Pageインターフェースにoutputを追加した

main.ts
for (const entry of walkSync(SOURCE_DIR)) {
  if (!entry.isFile || !entry.name.endsWith(".md")) {
    continue;
  }
  const ext = extname(entry.name);
  const filename = entry.name.replace(ext, "");

  const markdown = Deno.readTextFileSync(entry.path);
  const { meta, content } = Marked.parse(markdown);
  const name = (meta.title ? `${meta.title} | ` : "") + SITE_NAME;
  const relativePath = relative(SOURCE_DIR, entry.path);
  const output = entry.name == "index.md"
    ? relativePath.replace(/\.md$/, ".html")
    : relativePath.replace(/\.md$/, "/index.html");
  console.log(relativePath, "->", output, dirname(output));
  const dir = dirname(output);
  const path = "/" + dir.replace(/^\.$/, "");

  const html = content;

  if (relativePath.startsWith("layouts")) {
    layout[filename] = html;
  } else {
    pages.push({ path, output, name, html });
  }
}

console.log({ pages, layout });
layouts/footer.md -> layouts/footer/index.html layouts/footer
index.md -> index.html .
about.md -> about/index.html about
{
  pages: [
    {
      path: "/",
      output: "index.html",
      name: "Deno SSG site",
      html: '<h1 id="home">Home</h1>\n<p>Hello world!</p>\n\n<pre><code>echo &#39;Hello world!&#39;\n</code></pre>\n'
    },
    {
      path: "/about",
      output: "about/index.html",
      name: "About this Site | Deno SSG site",
      html: '<h1 id="about">About</h1>\n<p>Built for learning.</p>\n<p><img src="https://media.giphy.com/media/xuXz...'
    }
  ],
  layout: {
    footer: '<p>deno-md-site</p>\n<p>by <a href="https://github.com/kawarimidoll">kawarimidoll</a> made with ❤️</p...'
  }
}
kawarimidollkawarimidoll

で、チュートリアルと同じような形でビルドできる

main.ts
const getFaviconSvg = (emoji?: string) =>
  `<svg xmlns="http://www.w3.org/2000/svg"><text y="32" font-size="32">${emoji ||
    "🦕"}</text></svg>`;

const getNavigation = (currentPath: string) =>
  `<div id="nav">${
    pages.map(({ path, name }) => {
      const isSelectedPage = path === currentPath;
      const classes = `nav-item ${isSelectedPage ? "selected" : ""}`;
      return `<a class="${classes}" href=${path}>${name}</a>`;
    }).join(" | ")
  }</div>`;

const footer = layout.footer ? `<div id="footer">${layout.footer}</div>` : "";

const getHtmlByPage = ({ path, styles, name, html }: Page) => `
<!DOCTYPE html>
<html>
  <head>
    <title>${name}</title>
    <style>${styles}</style>
    <link rel="icon" href="/favicon.svg">
  </head>
  <body>
    ${getNavigation(path)}
    <div id="main">
      ${html}
    </div>
    ${footer}
  </body>
</html>`;

pages.forEach((page) => {
  const outputPath = join(BUILD_DIR, page.output);
  ensureFileSync(outputPath);
  Deno.writeTextFileSync(outputPath, getHtmlByPage(page));
});
Deno.writeTextFileSync(`${BUILD_DIR}/favicon.svg`, getFaviconSvg());
kawarimidollkawarimidoll

ナビゲーションバーにサイトタイトルまで出てしまうとかっこ悪いので修正
ページ名をDomParser使って取得する

main.ts
// パースするところのみ抜粋
const { meta, content: html } = Marked.parse(markdown);
const dom = domParser.parseFromString(html, "text/html");
if (!dom) {
  console.warn("invalid html");
  // ループを抜ける
  continue;
}
const h1 = dom.getElementsByTagName("h1");
const name = h1[0]?.textContent || "";
const title = (meta.title ? `${meta.title} | ` : "") + SITE_NAME;
kawarimidollkawarimidoll

こちらを参考にtwemojiをfaviconとして使用する
https://zenn.dev/catnose99/articles/3d2f439e8ed161

main.ts
import twemoji from "https://cdn.skypack.dev/twemoji@v13.1.0?dts";

const genEmojiFavicon = (favicon: string) => `
<link rel="icon" type="image/png" href="https://twemoji.maxcdn.com/v/13.0.2/72x72/${
  twemoji.convert.toCodePoint(favicon)
}.png" />
`;

const getHtmlByPage = ({ path, styles, favicon, title, html }: Page) => `
<!DOCTYPE html>
<html>
  <head>
    <title>${title}</title>
    <style>${styles}</style>
    ${genEmojiFavicon(favicon)}
  </head>
  <body>
    ${getNavigation(path)}
    <div id="main">
      ${html}
    </div>
    ${footer}
  </body>
</html>`;

pages.forEach((page) => {
  const outputPath = join(BUILD_DIR, page.output);
  ensureFileSync(outputPath);
  Deno.writeTextFileSync(outputPath, getHtmlByPage(page));
});

ただしtwemojiの型定義内部でHTMLElementsが使われている関係でtsconfigの設定が必要
https://deno.land/manual@v1.12.1/typescript/configuration#targeting-deno-and-the-browser

tsconfig.json
{
  "compilerOptions": {
    "lib": [
      "dom",
      "dom.iterable",
      "dom.asynciterable",
      "deno.ns",
      "deno.unstable"
    ]
  }
}

velociraptor.ymlにも追加しておく
https://velociraptor.run/docs/declarative-deno-options/#tsconfig

kawarimidollkawarimidoll

minifyerとかtag.tsとかを使ってリファクタリング
https://deno.land/x/minifier@v1.1.1
https://github.com/kawarimidoll/deno-github-contributions-api/blob/main/tag.ts
百数行に抑えられた

main.ts
import { BUILD_DIR, FAVICON, SITE_NAME, SOURCE_DIR } from "./config.ts";
import { Layout, Page } from "./types.ts";
import { rawTag as rh, tag as h } from "./tag.ts";
import {
  dirname,
  domParser,
  ensureFileSync,
  existsSync,
  join,
  Marked,
  minifyHTML,
  relative,
  twemoji,
  walkSync,
} from "./deps.ts";

const pages: Page[] = [];
const layout: Layout = {};

if (!existsSync(SOURCE_DIR)) {
  console.warn(`SOURCE_DIR: '${SOURCE_DIR}' is not exists!`);
  Deno.exit(1);
}
console.log(`Building site with '${SOURCE_DIR}' into '${BUILD_DIR}'`);

for (const entry of walkSync(SOURCE_DIR)) {
  if (!entry.isFile || !entry.name.endsWith(".md")) {
    continue;
  }

  const markdown = Deno.readTextFileSync(entry.path);
  const { meta, content: html } = Marked.parse(markdown);

  // check html
  const dom = domParser.parseFromString(html, "text/html");
  if (!dom) {
    console.warn("invalid html");
    continue;
  }

  const relativePath = relative(SOURCE_DIR, entry.path);

  if (relativePath.startsWith("layouts")) {
    // use filename without extensions
    const layoutName = entry.name.replace(/\.[^.]+$/, "");
    layout[layoutName] = html;
    continue;
  }

  const name = dom.getElementsByTagName("h1")[0]?.textContent || "";
  const prefix = entry.name === "index.md" ? "" : "/index";
  const output = relativePath.replace(/\.md$/, `${prefix}.html`);
  const path = "/" + dirname(output).replace(/^\.$/, "");
  const { title: pageTitle, styles, favicon = FAVICON } = meta;

  const title = (pageTitle ? `${pageTitle} | ` : "") + SITE_NAME;
  pages.push({ path, styles, favicon, output, title, html, name });
}

console.log({ pages, layout });

const genHtml = ({ path: currentPath, styles, favicon, title, html }: Page) =>
  "<!DOCTYPE html>" +
  rh(
    "html",
    rh(
      "head",
      rh("title", title),
      rh("style", styles),
      h("link", {
        // https://zenn.dev/catnose99/articles/3d2f439e8ed161
        rel: "icon",
        type: "image/png",
        href: `https://twemoji.maxcdn.com/v/13.0.2/72x72/${
          twemoji.convert.toCodePoint(favicon)
        }.png`,
      }),
    ),
    rh(
      "body",
      h(
        "div",
        { id: "nav" },
        pages.map(({ path, name }) =>
          h(
            "a",
            { class: `nav-item ${path === currentPath ? "selected" : ""}` },
            name,
          )
        ).join(" | "),
      ),
      h("div", { id: "main" }, html),
      layout.footer ? h("div", { id: "footer" }, layout.footer) : "",
    ),
  );

const minifyOptions = { minifyCSS: true, minifyJS: true };
pages.forEach((page) => {
  const outputPath = join(BUILD_DIR, page.output);
  ensureFileSync(outputPath);
  Deno.writeTextFileSync(outputPath, minifyHTML(genHtml(page), minifyOptions));
});
kawarimidollkawarimidoll

つい細かくリファクタリングしたくなるけど…どうせbodyのテンプレートは作り変えたいし まだここに時間を費やすべきではないな

kawarimidollkawarimidoll
[...dom.querySelectorAll("h2,h3")].forEach(elm => {
  console.log(elm.tagName, elm.textContent)
})

これでTOC作れる 速度は遅いかもしれんけど

kawarimidollkawarimidoll

まずPageインターフェースにtocを追加する

types.ts
  export interface Page {
    path: string;
    name: string;
    title: string;
    output: string;
    styles: string;
    favicon: string;
    html: string;
+   toc?: TocItem[];
  }

+ export interface TocItem {
+   level: number;
+   text: string;
+   href: string;
+ }

  export interface Layout {
    [key: string]: string;
  }

各ファイルをパースする部分の処理はこのようになる

main.ts
for (const entry of walkSync(SOURCE_DIR)) {
  if (!entry.isFile || !entry.name.endsWith(".md")) {
    continue;
  }

  const markdown = Deno.readTextFileSync(entry.path);
  const { meta, content } = Marked.parse(markdown);

  // check html
  const dom = domParser.parseFromString(content, "text/html");
  if (!dom) {
    console.warn("invalid html");
    continue;
  }

  const relativePath = relative(SOURCE_DIR, entry.path);

  if (relativePath.startsWith("layouts")) {
    // use filename without extensions
    const layoutName = entry.name.replace(/\.[^.]+$/, "");
    layout[layoutName] = content;
    continue;
  }

  const name = dom.getElementsByTagName("h1")[0]?.textContent || "";
  const prefix = entry.name === "index.md" ? "" : "/index";
  const output = relativePath.replace(/\.md$/, `${prefix}.html`);
  const path = "/" + dirname(output).replace(/^\.$/, "");
  const { title: pageTitle, styles, favicon = FAVICON } = meta;
  const title = (path === "/" ? "" : `${pageTitle || name} | `) + SITE_NAME;
  // ここまでは既存の処理

  // ここからTOC処理
  const toc: TocItem[] = [];

  // 対象のheaderをループ
  [...dom.querySelectorAll("h2,h3")].forEach((node) => {
    // NodeをElementに変換
    const elm = node as Element;
    const { nodeName, textContent: text } = elm;

    // whitespaceをハイフンに変換してheader要素のidに設定
    const id = String(text).trim().toLowerCase().replace(/\s+/g, "-");
    elm.attributes.id = id;

    // nodeNameは'H2', 'H3'なので数字部分を取り出して2を引く
    const level = Number(nodeName.slice(1)) - 2;

    toc.push({ level, text, href: "#" + id });
  });

  // 処理されたdomをページのhtmlとして保存
  const html = dom.body.innerHTML;
  pages.push({ path, styles, favicon, output, title, html, name, toc });
}
kawarimidollkawarimidoll

表示関数はこうなる

main.ts
const genHtml = (
  { path: currentPath, styles, favicon, title, html, toc }: Page,
) =>
  "<!DOCTYPE html>" +
  rh(
    "html",
    rh(
      "head",
      rh("title", title),
      rh("style", styles),
      h("link", {
        // https://zenn.dev/catnose99/articles/3d2f439e8ed161
        rel: "icon",
        type: "image/png",
        href: `https://twemoji.maxcdn.com/v/13.0.2/72x72/${
          twemoji.convert.toCodePoint(favicon)
        }.png`,
      }),
    ),
    rh(
      "body",
      div(
        { id: "nav" },
        pages.map(({ path, name }) => {
          const selected = path === currentPath ? "selected" : "";
          return a({ class: `nav-item ${selected}`, href: path }, name);
        }).join(" | "),
      ),
      toc && toc[0]
        ? h(
          "ul",
          { id: "toc" },
          ...toc.map(({ text, href }) => rh("li", a({ href }, text))),
        )
        : "",
      div({ id: "main" }, html),
      layout.footer ? div({ id: "footer" }, layout.footer) : "",
    ),
  );

しかしこれだとTOCがすべて同レベルで表示されてしまう(Zennはh2しか表示していないのでレベル分けを気にしなくて良いようになっている)
せっかくh2,h3で指定しているのでレベルを分けて表示させたい
形式としては以下のfromtoに変換したい

const from = [
  { level: 0, name: "a" },
  { level: 1, name: "b" },
  { level: 1, name: "c" },
  { level: 0, name: "d" },
  { level: 1, name: "e" },
  { level: 0, name: "f" },
];

const to = [
  {
    name: "a",
    children: [
      { name: "b" },
      { name: "c" },
    ],
  },
  {
    name: "d",
    children: [
      { name: "e" },
    ],
  },
  { name: "f" },
];
kawarimidollkawarimidoll

1階層まで(h2,h3のみ)ならこれで実現できるな

const to = [];
const genToc = (arr) => {
  for (let i = 0; i < arr.length;) {
    const { name, level } = arr[i];
    const children = [];
    i++;
    for (; i < arr.length; i++) {
      if (arr[i].level <= level) {
        break;
      }
      children.push({ name: arr[i].name });
    }
    const result = { name };
    if (children[0]) {
      result.children = children;
    }
    to.push(result);
  }
};

genToc(from);

でもh4以降も入れる場合は再帰にしないと無理だな

kawarimidollkawarimidoll

こうだな

const genToc = (arr) => {
  const out = [];
  for (let i = 0; i < arr.length;) {
    const { name, level } = arr[i];
    const extracted = [];
    i++;
    for (; i < arr.length; i++) {
      if (arr[i].level <= level) {
        break;
      }
      extracted.push(arr[i]);
    }
    console.log(i, name, extracted);
    const result = { name };
    if (extracted[0])result.children = genToc(extracted);
    out.push(result);
  }
  return out;
};

const to = genToc(from);
kawarimidollkawarimidoll

さらに上記のextractedを作るところを簡略化する
これは要するに「自分の次以降で、自分よりレベルの上のものの連続を取る」処理である
例えば、以下の配列がソースなら、aに着目してextractedを計算するとb,cが、fに着目するとg,h,iが、gに着目するとh,iが得られる

const from = [
  { level: 0, name: "a" },
  { level: 1, name: "b" },
  { level: 1, name: "c" },
  { level: 0, name: "d" },
  { level: 1, name: "e" },
  { level: 0, name: "f" },
  { level: 1, name: "g" },
  { level: 2, name: "h" },
  { level: 2, name: "i" },
  { level: 0, name: "j" },
];

これをつかって単純化すると以下のようになる

const genToc = (arr) => {
  const out = [];
  for (let i = 0; i < arr.length; i++) {
    const { name, level } = arr[i];

    // 自分より後のすべての要素
    const rest = arr.slice(i + 1);

    // 自分より後で、自分より先祖側(levelの値が小さい)の要素のインデックス
    // 見つからない(配列の残りがすべて子孫)なら-1
    const end = rest.findIndex((item) => item.level <= level);

    // 自分の子孫となる部分配列
    const extracted = end < 0 ? rest : rest.slice(0, end);

    // 子孫の数だけインデックスをすすめる
    i += extracted.length;

    const children = genToc(extracted);
    out.push({ name, children });
  }
  return out;
};
kawarimidollkawarimidoll

ということで仕上げた

levelArrayItem[]LeveledItem[]に、flatLevelArrayでその逆を行う

levelArray.ts
import { assertEquals } from "https://deno.land/std@0.102.0/testing/asserts.ts";

const from = [
  { level: 0, name: "a" },
  { level: 1, name: "b" },
  { level: 1, name: "c" },
  { level: 0, name: "d" },
  { level: 1, name: "e" },
  { level: 0, name: "f" },
  { level: 1, name: "g" },
  { level: 2, name: "h" },
  { level: 2, name: "i" },
  { level: 0, name: "j" },
];
const target = [
  {
    level: 0,
    name: "a",
    children: [
      { level: 1, name: "b", children: [] },
      { level: 1, name: "c", children: [] },
    ],
  },
  {
    level: 0,
    name: "d",
    children: [
      { level: 1, name: "e", children: [] },
    ],
  },
  {
    level: 0,
    name: "f",
    children: [
      {
        level: 1,
        name: "g",
        children: [
          { level: 2, name: "h", children: [] },
          { level: 2, name: "i", children: [] },
        ],
      },
    ],
  },
  { level: 0, name: "j", children: [] },
];

interface Item {
  level: number;
  // deno-lint-ignore no-explicit-any
  [key: string]: any;
}
interface LeveledItem extends Item {
  children: LeveledItem[];
}

const levelArray = (arr: Item[]): LeveledItem[] => {
  let breaker = 0;
  const out = [];
  for (let i = 0; i < arr.length; i++) {
    if (breaker++ > 20) {
      console.log("break!!!");
      return [];
    }
    const { level } = arr[i];

    const rest = arr.slice(i + 1);
    const end = rest.findIndex((item) => item.level <= level);
    const extracted = end < 0 ? rest : rest.slice(0, end);

    const children = levelArray(extracted);
    out.push({ ...arr[i], children });

    i += extracted.length;
  }
  return out;
};

const flatLevelArray = (arr: LeveledItem[]): Item[] => {
  let breaker = 0;
  const out = [];
  for (let i = 0; i < arr.length; i++) {
    if (breaker++ > 20) {
      console.log("break!!!");
      return [];
    }
    const extractedChildren = flatLevelArray(arr[i].children);
    const item: Item = arr[i];
    delete item.children;
    out.push(item);
    extractedChildren.forEach((child) => out.push(child));
  }
  return out;
};

const to = levelArray(from);

assertEquals(to, target);
assertEquals(flatLevelArray(to), from);

console.log("works fine!");
kawarimidollkawarimidoll

LeveledItemItemにまとめて簡略化 fromtoの定義部は省略

levelArray.ts
interface Item {
  level: number;
  // deno-lint-ignore no-explicit-any
  [key: string]: any;
  children?: Item[];
}

const levelArray = (arr: Item[]): Item[] => {
  const out = [];
  for (let i = 0; i < arr.length; i++) {
    const { level } = arr[i];

    const rest = arr.slice(i + 1);
    const end = rest.findIndex((item) => item.level <= level);
    const extracted = end < 0 ? rest : rest.slice(0, end);

    const children = levelArray(extracted);
    out.push({ ...arr[i], children });

    i += extracted.length;
  }
  return out;
};

const flatLevelArray = (arr: Item[]): Item[] => {
  const out: Item[] = [];
  arr.forEach((item) => {
    const extractedChildren = flatLevelArray(item.children || []);
    delete item.children;
    out.push(item);
    extractedChildren.forEach((child) => out.push(child));
  });
  return out;
};

assertEquals(levelArray(from), to);
assertEquals(flatLevelArray(levelArray(from)), from);

console.log("works fine!");
kawarimidollkawarimidoll

ここまですごい悩んで作ったけど

- [header a](#header-a)
  - [header a-1](#header-a-1)
  - [header a-2](#header-a-2)
- [header b](#header-b)

これをMarked.parse()に渡せば良いやん?自分でHTMLゴリゴリやる必要ないやん??ということに気づいた
やはり散歩すると新しい発想が降りてくるな…

kawarimidollkawarimidoll

結果これだけでした

main.ts
+ const headerLinks: string[] = [];
  [...dom.querySelectorAll("h2,h3")].forEach((node) => {
    const elm = node as Element;
    const { nodeName, textContent: text } = elm;
    const id = String(text).trim().toLowerCase().replace(/\s+/g, "-");
    elm.attributes.id = id;
    // nodeName is 'H2', 'H3', 'H4', 'H5', 'H6'
    const level = Number(nodeName.slice(1)) - 2;
+   headerLinks.push(`${"  ".repeat(level)}- [${text}](#${id})`);
  });
+ const { content: toc } = Marked.parse(headerLinks.join("\n"));

- const html = dom.body.innerHTML;
+ const html = h("div", { id: "toc" }, toc) + dom.body.innerHTML;
  pages.push({ path, styles, favicon, output, title, html, name });

これでpage.htmlを表示すると頭にいい感じに字下げされたtocがつく
かんたんや

kawarimidollkawarimidoll

カスタムブロックの定義は以下

https://github.com/ts-stack/markdown/tree/bb47aa8e625e89e6aa84f49a98536a3089dee831#example-of-setting-a-simple-block-rule

main.ts
Marked.setBlockRule(/^::: *(\w+)( *\w+)?\n([\s\S]+?)\n:::/, function (execArr) {
  const [, channel, title, content] = execArr ?? [];
  if (!channel) {
    return "";
  }
  const html = Marked.parse(content).content;
  switch (channel) {
    case "details": {
      return `<details><summary>${title}</summary>${html}</details>`;
    }
  }
  return `<div class="${channel}">${html}</div>`;
});

Overriding renderer methods使えば日本語idも作れるな こりゃ

kawarimidollkawarimidoll

できた…

main.ts
class MyRenderer extends Renderer {
  heading(text: string, level: number) {
    const id = String(text).trim().toLocaleLowerCase().replace(/\s+/g, "-");
    return `<h${level} id="${id}">${text}</h${level}>`;
  }
}
Marked.setOptions({ renderer: new MyRenderer() });

すごいなこれ 想像以上につよい