Typstでリンク先のURLを脚注に出したい、自動で
背景
紙版と電子版の両方のフォーマットで出力することが想定される場合、記事中のリンクのURLを脚注に示したい、ということがあると思います。
Typstではlinkをカスタマイズすることでこれが実現できます。ただ、いくつか難しい点があったので、ここにまとめておきます。試したことを順を追って書いていきますので、結果だけ知りたい方は「まとめ」を見てください。
footnote に URL を出す
まず思いつくのは以下のようなやり方です。
// 大体それっぽく動く
#show link: it => {
  it
  footnote(it.dest)
}
#link("https://example.com")[Example]
これでほとんど期待通りに動きます。ただ、すぐに気がつく問題があります。脚注にある URL はリンクになっていない、つまりクリックしてもリンク先に遷移できないのです。
footnote の中で link() を使う→失敗
そこで、footnote()の中でlink()を使う方法を試してみました。しかし、こうすると無限ループが発生するようで、コンパイルが終わらなくなってしまいます。
// これはコンパイルが終わらない
#show link: it => {
  it
  footnote(link(it.dest, it.dest))
}
#link("https://example.com")[Example]
実は、この他にも問題があります。
冗長な脚注が出力される問題
記事中に URL が書かれていて自動リンクになると、冗長な footnote が生成されてしまうのです。具体例を見てみましょう。
// 冗長なfootnoteができてしまう例
#show link: it => {
  it
  footnote(it.dest)
}
https://example.com
こうすると、本文には https://example.com 1 と出力され(1は脚注へのリンク)、脚注には 1 https://example.com が出力されます。本文中にURLが書かれているので、わざわざ脚注にもURLを出す必要はありません。こういうケースは通常のlinkとして振る舞ってほしいわけです。
比較演算でいける?→失敗
Typstが自動でリンクを生成する場合は、リンクテキストとリンク先が同一のリンクが生成されるようです。したがって、比較演算子を使って比較すれば良いだろう...と思うのですが、そこまで簡単ではありません。
// うまくいかない
#show link: it => {
  it
  if it.dest != it.body { // この条件は常に成立してしまう
    footnote(it.dest)
  }
}
https://example.com
it.dest は string 型で、 it.body は content 型なので、常に it.dest != it.body が成立してしまい、必ず脚注が出てしまいます。
 contentをstringに変換してから比較する
contentをstringに変換してから比較したいところですが、これがそれほど簡単ではないようです。検索していると https://github.com/typst/typst/issues/2196#issuecomment-1728135476 にやり方を書いているコメントがありました。content-to-string()に名前を変えつつ、以下のようにしてみます。
// リンクテキストとリンク先が同一の場合はリンクしない
#let content-to-string(content) = {
  if content.has("text") {
    content.text
  } else if content.has("children") {
    content.children.map(content-to-string).join("")
  } else if content.has("body") {
    to-string(content.body)
  } else if content == [ ] {
    " "
  }
}
#show link: it => {
  it
  if it.dest != content-to-string(it.body) {
    footnote(it.dest)
  }
}
#link("https://example.com")[Example]
https://example.com
こうすると、#link で書いた方だけが脚注になります。
脚注がリンクにならない問題、再訪
実は、冗長な脚注が出る問題が解決できると、脚注のURLがクリッカブルではない問題も解決できるようになります。以下のようにします。
// 脚注もクリッカブルになる
#let content-to-string(content) = {
  if content.has("text") {
    content.text
  } else if content.has("children") {
    content.children.map(content-to-string).join("")
  } else if content.has("body") {
    to-string(content.body)
  } else if content == [ ] {
    " "
  }
}
#show link: it => {
  it
  if it.dest != content-to-string(it.body) {
    footnote(link(it.dest, it.dest))
  }
}
#link("https://example.com")[Example]
https://example.com
今度は、再帰的にlinkが呼び出されても、リンクテキストとリンク先が一致するケースになるため、脚注がさらに追加されることはありません。これで完成です。めでたしめでたし。
と思ったのですが、まだ問題がありました。Typstのリンクは外部URLだけでないのです。
内部リンクを壊さないようにする
たとえば以下のような原稿があるとエラーが出てしまいます。
// 内部リンクがあるとエラーがでる
#let content-to-string(content) = {
  if content.has("text") {
    content.text
  } else if content.has("children") {
    content.children.map(content-to-string).join("")
  } else if content.has("body") {
    to-string(content.body)
  } else if content == [ ] {
    " "
  }
}
#show link: it => {
  it
  if it.dest != content-to-string(it.body) {
    footnote(link(it.dest, it.dest))
  }
}
= chapter
<chapter>
#link(<chapter>)[Internal link]
この場合、 id.dest が label 型になっています。 type(it.dest) が string であることを確認すればよいでしょう。
まとめ
というわけで、まとめると、以下のようにしてリンクのURLを自動で脚注に出力できるようになります。
// 必要に応じてリンクを脚注に自動で出す
#let content-to-string(content) = {
  if content.has("text") {
    content.text
  } else if content.has("children") {
    content.children.map(content-to-string).join("")
  } else if content.has("body") {
    to-string(content.body)
  } else if content == [ ] {
    " "
  }
}
#show link: it => {
  it
  if type(it.dest) == "string" and it.dest != content-to-string(it.body) {
    footnote(link(it.dest, it.dest))
  }
}
= chapter
<chapter>
#link("https://example.com")[Example]
https://example.com
#link(<chapter>)[Internal link]
Discussion