例

Cosense Web の /help-jp/その他の書き方 を変換したものです。実装はそのまま動くコードで、この画面もそれで作っています。

そのまま変換する

parse して toHtml に渡すだけの、いちばん短い書きかたです。

import { parse } from "@cosense-toolbox/parser"
import { toHtml } from "@cosense-toolbox/parser/html"
import style from "@cosense-toolbox/style/style.css?raw"

const source = [
  "その他の書き方",
  "基本的な[ブラケティング]の他にも、色々な[記法]があります",
  "",
  "外部リンク (一般のWebページへのリンク)",
  " URLを書くとリンクになる",
  "  https://yahoo.co.jp",
  "	`[URL タイトル]`",
  "	 [https://yahoo.com Yahoo!]",
  " `[タイトル URL]`逆順でもok",
  "  [Yahoo! https://yahoo.com]",
  "",
  "画像",
  "	`[画像URL]`",
  "  [https://i.gyazo.com/da78df293f9e83a74b5402411e2f2e01.png]",
  " `[[画像URL]]`",
  "  横幅いっぱい、高さ制限無しで大きな画像を表示",
  "",
  "リンク付き画像",
  "	`[リンク先URL 画像URL]`",
  "  [http://yahoo.co.jp https://i.gyazo.com/da78df293f9e83a74b5402411e2f2e01.png]",
  "",
  "別プロジェクトへのリンク",
  " `/プロジェクト名/ページ名`",
  " [/icons]",
  " [/icons/すごい]",
  "",
  "アイコン",
  " [/qaynam/qaynam.icon]",
  " [/Icons2/trainSymbol-M.icon]",
  "",
  "[数式]",
  " [$ \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}]",
  "",
  "[HashTag]",
  " #HashTag",
  "",
  "引用",
  " > 行頭に`>`を書くと引用になります",
  "",
  "コード",
  " バッククオート`\\``で囲む",
  "  `function() {  return \\`hello\\` }`",
  "",
  "[コードブロック]",
  " code:hello.js",
  "  function () {",
  "    alert(document.location.href)",
  '    console.log("hello")',
  "    // コメントも書けるぞ",
  "  }",
  "",
  "[テーブル]",
  " `table:テーブル名` の後、インデントしてtab区切り",
  " table:テーブルの例",
  "  abc	def",
  "  12345	6789",
  "  長い長い文字列	短い文字列",
  "",
  "コマンドライン",
  " 行頭に`$`もしくは`%`",
  " $ git reset --hard HEAD^",
  " % cp a.txt b.txt",
  "",
  "[[強調]]と[*** 大きな文字]",
  "	`[[強調]]` ⇒ [[強調]]",
  " `[* 強調]` ⇒ [* 強調]",
  " `[**** もっと大きな文字]` => [**** もっと大きな文字]",
  "",
  "[/ 斜体]",
  " `[/ 斜体文字]` ⇒ [/ 斜体文字]",
  " `[/* 太字斜体文字]` => [/* 太字斜体文字]",
  "",
  "[- 打ち消し線]",
  " `[- 打ち消し]` ⇒ [- 打ち消し]",
].join("\n")

const body = toHtml(parse(source))

export const html = `<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>${style}</style>
  </head>
  <body>
    ${body}
  </body>
</html>`

ただし、アイコンが画像になりません。アイコンの画像は Cosense の/api/pages/{project}/{user}/icon にありますが、[user.icon]のように記法にプロジェクト名が書かれていないことがあるので、パーサーは既定では URL を組み立てないからです。 リンク先が /タイトル になっているのも同じ理由です。

リンク先とアイコンを渡す

そこで pageUrl と iconImageUrl を渡します。プロジェクト名を知っているのは呼び出し側なので、URL の組み立ても呼び出し側が行います。

import { parse } from "@cosense-toolbox/parser"
import { toHtml } from "@cosense-toolbox/parser/html"
import { collect } from "@cosense-toolbox/parser/utils"
import style from "@cosense-toolbox/style/style.css?raw"

const project = "help-jp"

const source = [
  "リンクとアイコン",
  " 同じプロジェクトのページ",
  "  [ブラケティング]",
  "  #HashTag",
  " 別のプロジェクトのページ",
  "  [/icons/すごい]",
  " アイコン",
  "  [/qaynam/qaynam.icon]",
  "  [/Icons2/trainSymbol-M.icon]",
].join("\n")

const encodePath = (path: string) => path.split("/").map(encodeURIComponent).join("/")

/** `/project/page` はそのまま、`page` は `project` のページとして、Cosense のパスにする。 */
const pagePath = (title: string) =>
  title.startsWith("/")
    ? encodePath(title)
    : `/${encodeURIComponent(project)}/${encodeURIComponent(title)}`

/**
 * アイコンの画像を取ってきて、`data:` URL にする。取れなければ null (ユーザー名のリンクになる)。
 *
 * API の URL を `<img>` にそのまま入れることはできない。API は `Cross-Origin-Resource-Policy: same-origin`
 * を返すので、ほかのサイトからは読めない。転送先の URL も、期限付き (数分で切れる) のことがある。
 */
async function fetchIconDataUrl(user: string): Promise<string | null> {
  try {
    const res = await fetch(`https://scrapbox.io/api/pages${pagePath(user)}/icon`, {
      signal: AbortSignal.timeout(4000),
    })
    if (!res.ok) return null
    const type = res.headers.get("content-type") ?? "image/png"
    const bytes = Buffer.from(await res.arrayBuffer()).toString("base64")
    return `data:${type};base64,${bytes}`
  } catch {
    return null
  }
}

const page = parse(source)

const iconByUser = new Map(
  await Promise.all(
    collect(page, "icon").map(
      async (icon) => [icon.user, await fetchIconDataUrl(icon.user)] as const,
    ),
  ),
)

const body = toHtml(page, {
  pageUrl: (title) => `https://scrapbox.io${pagePath(title)}`,
  iconImageUrl: (icon) => iconByUser.get(icon.user) ?? null,
})

export const html = `<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>${style}</style>
  </head>
  <body>
    ${body}
  </body>
</html>`

Markdown から読む

逆向きに、ChatGPT の回答のような Markdown を@cosense-toolbox/markdown で読むと、Cosense のページに貼れる記法のテキストや、Cosense の AST になります。

import { markdownToCosenseText, parseFromMarkdown } from "@cosense-toolbox/markdown"
import { toHtml } from "@cosense-toolbox/parser/html"
import style from "@cosense-toolbox/style/style.css?raw"

// ChatGPT の回答のような Markdown
const markdown = `## React の useEffect

**useEffect** は、副作用を扱うための *フック* です。詳しくは[公式ドキュメント](https://react.dev/reference/react/useEffect)にあります。

### 主なポイント

1. **依存配列**を指定する
   - 空配列なら初回だけ
   - 省略すると毎回
2. クリーンアップ関数を返せる

\`\`\`tsx
useEffect(() => {
  const id = setInterval(tick, 1000)
  return () => clearInterval(id)
}, [])
\`\`\`

> **注意:** 開発中の Strict Mode では 2 回呼ばれます。

| 書き方 | 実行のタイミング |
| --- | --- |
| \`useEffect(fn)\` | 毎回 |
| \`useEffect(fn, [])\` | 初回だけ |

- [x] 依存配列を確かめた
- [ ] テストを書く
`

// Cosense のページに貼るなら、記法のテキストにする
export const text = markdownToCosenseText(markdown)

// 描画するなら、AST を toHtml に渡す
const body = toHtml(parseFromMarkdown(markdown))

export const html = `<!doctype html>
<html lang="ja">
  <head>
    <meta charset="utf-8" />
    <style>${style}</style>
  </head>
  <body>
    ${body}
  </body>
</html>`