Denoでmarkdown処理の練習
解説記事:
リポジトリ: デモサイト: おもしろそうじゃん?- ひとつの
.md
ファイルを受け取ってWebサイトを生成 - Navbar/content/footerの3ブロックのページ
- 構造に沿ったフォルダ構成
- ナビゲーションリンクは有効なhrefを保持
- 自動生成CSS
- SVGファビコン
とりあえずrepoを作った
main.ts
に書いていくもよう
単一の.md
ファイルをソースとし、deno run main.ts src.md ./dst
みたいな形で実行する
ということでCLI引数の扱いを作っていこう
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
だけだと怒られて、一つ以上引数を指定すると通るようになる
依存関係のインポート、型とグローバル変数の定義
// 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
を追加している
では機能を追加していく
まずはファイルの読み込み
読み込む.md
ファイルのルールは以下のようにする
- 先頭に
---
で囲んだYAML Front Matterを配置し、タイトル(必須)、スタイル(任意)、絵文字ファビコン(任意)を定義 - 単一ファイル内に複数ページを入れるため、
+++
で分割- その下に
/[path]:[title]
形式でページのパスとタイトルを定義 -
layout:[name]
形式でレイアウト要素を定義
- その下に
- 自動で
/home:Home
をホームページとする
---
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
これを読み込んで使用する
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"
]
項目を読み込んで分割して表示できた
Marked
を使ってデータを取り出してみよう
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本体の抽出に入る
各コンポーネントのパースを追加
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に入れたら良いのでは…)
String.prototype.match()で抽出する
match()
の出力はRegExp String Iterator
で返ってくるので分割代入でガッと取得する
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" }
1行目に入ってくることがわかってるのにこういう抽出をするのはちょっと気に食わないな
自分なりに変えよう
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>" }
で、これをpath
が"layout"
かで分岐してグローバル変数に追加していく
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>" }
}
HTMLとして仕上げるためのをテンプレートとヘルパーを作る
ルートディレクトリにTOPページとスタイルシートがあり、他のページはディレクトリ内に入っているという想定でヘルパーを作成
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>`;
続いてナビゲーションバーと全体のテンプレートを作成
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"
を入れているところもガンガン省いている
そしたらビルドを行う
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()
で書き出すという方法を取る
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ファイルが生成されている
Assetsの書き出しも追加 これは/assets
内に入れても良いかもなあ
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
使いたいよね
/// <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));
});
起動成功!良いやん!
ky
とmime
使って少し綺麗に
/// <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
を作るところがちょっとかっこ悪いなあ
- 単一ファイルじゃなくて複数ファイルの構造を尊重したまま静的ディレクトリを生成したい
- そのへんの設定はCLI引数じゃなくて
config.ts
から読み込みたい deploy環境だとfs
が無いのでconfig.json
とかは無理でしょ多分 - リンクとか画像とかは問題なさそうだ
では複数ファイルから静的サイトを作ってみよう
上で使っていたexample.md
をindex.md
とabout.md
とlayouts/footer.md
に分割して、以下のようなファイル構造にする(必要なファイルのみ抜粋)
.
├── config.ts
├── deps.ts
├── docs/
│ ├── about.md
│ ├── index.md
│ └── layouts/
│ └── footer.md
├── main.ts
└── server.ts
これでまずはdocs
ディレクトリ内を表示する
export const SOURCE_DIR = "docs";
export const BUILD_DIR = "build";
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、各ファイルを確認できている
各ファイルはfrontmatterと内容を持つ
---
styles: >
body { color: #22a6b3; }
---
# Home
Hello world!
---
title: About this Site
styles: >
body { color: #733aff; }
favicon: 🥳
---
# About
Built for learning.
![pikachu](https://media.giphy.com/media/xuXzcHMkuwvf2/giphy.gif)
deno-md-site
by [kawarimidoll](https://github.com/kawarimidoll) made with ❤️
config.ts
にデフォルト値を追加
export const SOURCE_DIR = "docs";
export const BUILD_DIR = "build";
+ export const SITE_NAME = "Deno SSG site";
+ export const FAVICON = "🦕";
ページごとにパースして表示してみよう
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 'Hello world!'
</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>
良いでしょう
書き出しパスを作っていく
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じゃないけどリファクタリングはあとに回そう
ディレクトリに応じpages
かlayout
へ登録していく
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 'Hello world!'\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...'
}
}
これでチュートリアルとほぼ同じ結果になったかな
path
の解釈が違ったので少し修正
Page
インターフェースにoutput
を追加した
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 'Hello world!'\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...'
}
}
で、チュートリアルと同じような形でビルドできる
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());
ナビゲーションバーにサイトタイトルまで出てしまうとかっこ悪いので修正
ページ名をDomParser
使って取得する
// パースするところのみ抜粋
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;
こちらを参考にtwemojiをfaviconとして使用する
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
の設定が必要
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"dom.asynciterable",
"deno.ns",
"deno.unstable"
]
}
}
velociraptor.yml
にも追加しておく
minifyerとかtag.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));
});
つい細かくリファクタリングしたくなるけど…どうせbodyのテンプレートは作り変えたいし まだここに時間を費やすべきではないな
[...dom.querySelectorAll("h2,h3")].forEach(elm => {
console.log(elm.tagName, elm.textContent)
})
これでTOC作れる 速度は遅いかもしれんけど
TOCできた
まずPage
インターフェースにtoc
を追加する
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;
}
各ファイルをパースする部分の処理はこのようになる
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 });
}
表示関数はこうなる
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
で指定しているのでレベルを分けて表示させたい
形式としては以下のfrom
をto
に変換したい
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" },
];
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以降も入れる場合は再帰にしないと無理だな
こうだな
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);
さらに上記の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;
};
ということで仕上げた
levelArray
でItem[]
をLeveledItem[]
に、flatLevelArray
でその逆を行う
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!");
LeveledItem
をItem
にまとめて簡略化 from
とto
の定義部は省略
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!");
ここまですごい悩んで作ったけど
- [header a](#header-a)
- [header a-1](#header-a-1)
- [header a-2](#header-a-2)
- [header b](#header-b)
これをMarked.parse()
に渡せば良いやん?自分でHTMLゴリゴリやる必要ないやん??ということに気づいた
やはり散歩すると新しい発想が降りてくるな…
結果これだけでした
+ 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
がつく
かんたんや
カスタムブロックの定義は以下
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も作れるな こりゃ
できた…
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() });
すごいなこれ 想像以上につよい