残念ながらAsanaでは、コードブロックをハイライトする機能が提供されていませんので作って公開しました。
https://chrome.google.com/webstore/detail/asana-highlighter/lgofbppgpileldekmjbomfdodkhholna

こんな感じで動作します。

ソースコードはこちらで公開しています。
https://github.com/rinoguchi/asana_highlighter

動機

現在所属している会社ではチケット管理にAsanaを利用しているのですが、Asanaにはコードブロックをハイライトする機能がありません。

機能開発やテックサポートなどをしていると、ソースコードをAsana上に保存しておきたいケースはそれなりにあり、ソースコードが良い感じにハイライトされてないと頭に入ってきません。とても嫌な感じです。

Asana上でもリクエストがあがってるのですが、年単位で放置されており、すぐに対応されることはなさそうです。。

幸い私は過去にChrome拡張を作って公開したこともありますし、完璧を求めなければそんなに難しい内容でもないので、作ってみることにしました。

コードブロックのハイライト処理

ハイライト処理自体は、highlight.jsで行なっています。
ページ内のコメント欄をquerySelectorAllで取得して、コードブロック(```で囲んだ部分)を置き換えるだけです。

実装上の注意点は、コメントでコード中に記載したので、そちらを確認ください。

import hljs from "highlight.js";
import "highlight.js/styles/hybrid.css"; // 必要なcssを読み込み。css-loader+style-loaderで最終的に<style>タグとしてDOMに追加される

function render() {
  // コメントリストを取得
  const comments: NodeListOf<Element> = document.querySelectorAll(".RichText");
  comments.forEach((c) => {
    // 装飾のために挿入されているHTMLタグやHTML特殊文字がそのまま表示されないように置換
    const newInnerHTML = c.innerHTML.replace(
      /```(.*?)<br>(.*?)```/g,
      (_all: string, lang: string | undefined, src: string) => {
        const fixedSrc = src
          .replace(/<br>/g, "\n")
          .replace(/<code>|<\/code>/g, "")
          .replace(/<strong>|<\/strong>/g, "")
          .replace(/</g, "<")
          .replace(/>/g, ">")
          .replace(/"/g, '"')
          .replace(/&/g, "&")
          .replace(/ /g, " ");

        let highlightedSrc;
        if (!lang) {
          // 言語が指定されてない場合は自動ハイライト
          highlightedSrc = hljs.highlightAuto(fixedSrc).value;
        } else {
          // サポートされている言語の場合は言語指定でハイライト
          const fixedLang = lang.toLowerCase().trim();
          if (hljs.listLanguages().includes(fixedLang)) {
            highlightedSrc = hljs.highlight(fixedSrc, {
              language: lang.toLowerCase().trim(),
            }).value;
          } else {
            // サポートされてない言語の場合は自動ハイライト
            highlightedSrc = hljs.highlightAuto(fixedSrc).value;
          }
        }
        // highlight.jsのスタイルに合わせてタグを追加
        return `<pre><code class="hljs">${highlightedSrc}</code></pre>`;
      }
    );
    // 変更がある場合は上記の内容でHTMLを置き換え
    if (newInnerHTML !== c.innerHTML) {
      c.innerHTML = newInnerHTML;
    }
  });
}

setInterval(render, 1000); // 1秒に1回上記の描画処理を実行
  • HTMLタグやHTML特殊文字に関しては、他にもいろんなパターンがありそうなので、変なタグが表示されるケースもありそうです
  • Description欄は入力途中の制御が難しかったので対応はやめておきました

Chrome拡張としての設定

manifest.json

Chrome拡張として利用するためには、manifest.jsonが必要になります。
今回は、特定のURLの場合に上で作ったスクリプトを実行するだけなので、以下のようにとてもシンプルです。

{
  "name": "asana highlighter",
  "description": "highlight the source code block on Asana pages",
  "version": "1.0.0",
  "manifest_version": 3,
  "icons": {
    "48": "icons/icon_48.png",
    "128": "icons/icon_128.png"
  },
  "content_scripts": [
    {
      "matches": ["https://app.asana.com/*"],
      "js": ["highlighter.js"]
    }
  ]
}
  • 公式ページにも以下のように書いてあるように、manifest_versionは、3を設定する必要があります

    As of January 17, 2022 Chrome Web Store has stopped accepting new Manifest V2 extensions. We strongly recommend that new extensions target Manifest V3.

  • ストアで公開するために、iconsも設定してあります
  • content_scriptsは、対象のWebページのコンテキストでスクリプトを実行する際に利用する機能です。AsanaのURLの場合に、highlight.jsを実行する設定にしてあります。

webpack設定

Chrome拡張としてブラウザに登録するためには、manifest.jsonとそこで参照しているファイル群を一つのフォルダにまとめる必要がありますので、webpackでバンドルしました。
今回のケースでは、以下のようなフォルダ構成を出力します。

dist
├── highlighter.js
├── icons
│   ├── icon_128.png
│   └── icon_48.png
└── manifest.json
  • highlight.jsは、highlight.tsを元にwebpackでトランスパイル+バンドルしたものです
  • iconsmanifest.jsonsrcフォルダ配下のものを単純にコピーするだけです

webpack.config.jsは以下のようになりました。ポイントはコメントで記載してます。

const CopyPlugin = require("copy-webpack-plugin");

module.exports = {
  mode: "development",
  context: __dirname + "/src",
  devtool: "source-map", // NOTE: バンドル後のJSでevalを使わない。manifest V3ではevalは許可されていない。「Uncaught EvalError: Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source of script in the following Content Security Policy directive: "script-src 'self'".」回避
  entry: {
    highlighter: "./highlighter.ts",
  },
  output: {
    path: __dirname + "/dist",
    filename: "[name].js",
  },
  resolve: {
    extensions: [".ts", ".js", ".css"],
  },
  module: {
    rules: [
      {
        test: /\.css$/,
        use: [
          "style-loader", // 読み込んだスタイルを<style>タグとしてhtmlに出力
          "css-loader", // .cssファイルをJSで文字列として読み込む。末尾から実行される
        ],
      },
      {
        test: /\.ts$/,
        loader: "ts-loader",
      },
      {
        test: /\.js$/,
        loader: "babel-loader",
        exclude: /node_modules/,
      },
    ],
  },
  plugins: [
    // iconsとmanifest.jsonを単純にコピー
    new CopyPlugin({
      patterns: [
        { from: "icons", to: "icons" },
        {
          from: "manifest.json",
          to: "manifest.json",
        },
      ],
    }),
  ],
  externals: {},
};

webpackを実行すると、無事distフォルダに必要なファイルが出力されました。

$ webpack

assets by path icons/*.png 6.81 KiB
  asset icons/icon_128.png 5 KiB [compared for emit] [from: icons/icon_128.png] [copied]
  asset icons/icon_48.png 1.81 KiB [compared for emit] [from: icons/icon_48.png] [copied]
asset highlighter.js 1.53 MiB [compared for emit] (name: highlighter) 1 related asset
asset manifest.json 346 bytes [emitted] [from: manifest.json] [copied]
runtime modules 937 bytes 4 modules
modules by path ../node_modules/highlight.js/lib/languages/*.js 1.35 MiB 192 modules
modules by path ../node_modules/style-loader/dist/runtime/*.js 5.75 KiB 6 modules
modules by path ../node_modules/highlight.js/styles/*.css 3.6 KiB
  ../node_modules/highlight.js/styles/hybrid.css 1.04 KiB [built] [code generated]
  ../node_modules/css-loader/dist/cjs.js!../node_modules/highlight.js/styles/hybrid.css 2.56 KiB [built] [code generated]
modules by path ../node_modules/highlight.js/lib/*.js 85.4 KiB
  ../node_modules/highlight.js/lib/index.js 12 KiB [built] [code generated]
  ../node_modules/highlight.js/lib/core.js 73.4 KiB [built] [code generated]
modules by path ../node_modules/css-loader/dist/runtime/*.js 2.94 KiB
  ../node_modules/css-loader/dist/runtime/sourceMaps.js 688 bytes [built] [code generated]
  ../node_modules/css-loader/dist/runtime/api.js 2.26 KiB [built] [code generated]
./highlighter.ts 1.29 KiB [built] [code generated]
../node_modules/highlight.js/es/index.js 203 bytes [built] [code generated]
webpack 5.72.0 compiled successfully in 2994 ms

$ tree dist

dist
├── highlighter.js
├── highlighter.js.map
├── icons
│   ├── icon_128.png
│   └── icon_48.png
└── manifest.json

試しに実行

必要なファイル群ができたので、Chrome拡張として登録してみます。

  • Chrome拡張機能画面を開く
    • chrome://extensions/
  • デベロッパーモードを ON
  • パッケージ化されてない拡張機能を読み込むをクリック
  • 先ほど作成したdistフォルダを指定

これで、URLがhttps://app.asana.com/*にマッチするページを開くと今回作ったhighlight.jsが実行されて、コードブロックがハイライトされるはずです。

実際に、Asanaのチケットを表示してみたところ、以下のようにうまくコードブロックがはハイライトされました^^

Storeに申請

昔ブログを書いてたので、それを見ながら申請しました。
iconとか画像が必要なぐらいで、特に難しいことはないと思います。

https://rinoguchi.net/2020/10/publish-chrome-extension.html

申請したら、翌日には公開されていました。

最後に

AsanaのコードブロックをハイライトするChrome拡張を作って公開しました。
本家で対応してくれるまでの間に合せとしては十分かな、と思ってます。
不具合を見つけたらプルリクをいただくか、ISSUEを登録いただけると助かります。