TypeScript × Vite でハッシュタグ機能を実装

# はじめに

**ハッシュタグ**は、SNS などで広く利用される、話題やカテゴリを示すキーワードです。

本記事では、Unicode Standard で規定されている[ハッシュタグの仕様](https://unicode.org/reports/tr31/#hashtag_identifiers) を元に、ハッシュタグ機能の実装します。この仕様はハッシュタグの共通的な処理を促進する一方で、実装の自由度も一定程度確保しています。

# ハッシュタグの仕様

ハッシュタグの仕様について簡単に説明します。

```:ハッシュタグの構文
<Hashtag-Identifier> := <Start> <Continue>* (<Medial> <Continue>+)*


`:=` : 右辺が左辺の構文を定義
`<>` : 定義された構文
`*`  : 直前の構文が 0 回以上繰り返されることを示す
`+`  : 直前の構文が 1 回以上繰り返されることを示す
`()` : グループ化を表す

Start    : ハッシュタグの開始文字
Continue : 続く文字
Medial   : オプションで使用できる文字
```

[ハッシュタグの仕様](https://unicode.org/reports/tr31/#hashtag_identifiers)には、さまざまなバリエーションが書かれていますが、今回は以下のようにいします。

- `Start` 文字は `#`
- `Continue` 文字には `XID_Continue` プロパティに `Extended_Pictographic` プロパティ、`Emoji_Component` プロパティ、`_`、`-`、`+` を加えたものを含め、`#` を除外
- `Medial` は空

ハッシュタグを解析する際には、**`Start` 文字の前に `Continue` 文字がない場合にのみハッシュタグとして認識**することが推奨されます。例えば、`foo#bar` ではハッシュタグは認識されませんが、`foo #bar` や `foo.#bar` ではハッシュタグとして認識されます。

ハッシュタグの仕様について詳しくは以下の記事で説明しています。

https://qiita.com/shikuno_dev/items/1d65e43631e33d047c9f

# ハッシュタグ機能の実装

## Vite による開発環境構築

### Vite とは

**Vite** は、2020 年にリリースされた高速な開発環境を提供するフロントエンド・ビルド・ツールで、オリジナルの開発者は Evan You 氏( `Vue.js` の作者)です。Vite は、ビルドシステムとしての役割以外にも開発サーバとしての役割も果たします。

> **互換性について**
> Vite は Node.js 18+、20+ のバージョンが必要です。ただし、一部のテンプレートではそれ以上のバージョンの Node.js を必要としますので、パッケージマネージャが警告を出した場合はアップグレードしてください。
> [はじめに | Vite](https://ja.vitejs.dev/guide/)

`node` と `npm` を使用できるか確認してください。

```bash:ターミナル
$ node -v
v20.9.0
$ npm -v
10.1.0
```

### 開発環境構築

1. ターミナル開き、デスクトップなどの任意のディレクトリに移動
2. **`npm init vite@latest`** コマンドを実行
   もし、以下のようなテキストが表示された場合は `y` を入力
   ```bash
   Need to install the following packages:
   create-vite@5.3.0
   Ok to proceed? (y)
   ```
3. **Project name** の項目で任意のプロジェクト名を入力
   今回は、プロジェクト名を **`hashtag`** とします。
4. **Select a framework** の項目で **`Vanilla`** を選択
5. **Select a variant** の項目で **`TypeScript`** を選択

```bash:ターミナル
$ npm init vite@latest
? Project name: › hashtag
? Select a framework: › - Use arrow-keys. Return to submit.
❯   Vanilla
? Select a variant: › - Use arrow-keys. Return to submit.
❯   TypeScript
```

1. **`hashtag`** ディレクトリに移動
2. **`npm install`** コマンドを実行し、`package.json` ファイル内に記述されたパッケージ（ `TypeScript` など）を一括でインストール（`node_modules` ディレクトリ と `package-lock.json` も同時に作成）
3. **`npm run dev`** コマンドを実行し、開発サーバを起動
4. Web ブラウザで **`http://localhost:5173/`** にアクセスし、**Vite + TypeScript**と書かれたページが表示される確認。

```bash:ターミナル
$ cd hashtag
$ npm install
$ npm run dev
```

以上で、開発環境の構築が完了しました。

## 実装

`foo #bar #foo#foo #b🦰ar foo.#bar` と入力すると、

<font color="blue">#bar</font> <font color="blue">#foo</font>#foo <font color="blue">#b🦰ar</font> foo.<font color="blue">#bar</font>

のようにハッシュタグとして認識される部分が<font color="blue">青色</font>に色付けされる機能を実装します。

### index.html

`index.html` は、Vite + TypeScript で構築されたフロントエンドアプリケーションの**エントリポイント**として機能します。

```html:index.html
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + TS</title>
  </head>
  <body>
    <div id="app"></div>
    <script type="module" src="/src/main.ts"></script>
  </body>
</html>
```

- `<div id="app"></div>`
  `body` 内の要素はシンプルで、id 属性が app の `div` タグだけです。
- `<script type="module" src="/src/main.ts"></script>`
  Vite は、`src` ディレクトリにある `main.ts` ファイルをエントリポイントとして、JavaScript コードをバンドルし、実行します。

### main.ts の編集

`main.ts` ファイルを以下のように編集します。

```typescript:src/main.ts
import { setupHashtag } from './hashtag.ts'

let app = document.querySelector<HTMLDivElement>('#app')!;
setupHashtag(app);
```

### hashtag.ts の編集

`counter.ts` ファイルのファイル名を `hashtag.ts` に変更し、ファイル内のコードを削除します。

#### 型を定義

```typescript:src/hashtag.ts
type TokenType = 'hashtag' | 'nonHashtag';

type Token = {
    type: TokenType;
    value: string;
};
```

#### 正規表現を追記

```typescript:src/hashtag.ts
const Start = /#/;
const Continue = /\p{XID_Continue}|\p{Extended_Pictographic}|\p{Emoji_Component}|[_+-]/u;
```

#### `setupHashtag` 関数を追記

```typescript:src/hashtag.ts
export function setupHashtag(element: HTMLDivElement) {

    let textarea = document.createElement('textarea');
    element.appendChild(textarea);

    let output = document.createElement('div');
    element.appendChild(output);


    textarea.addEventListener('input', (e) => {
        const { target } = e;

        if (!(target instanceof HTMLTextAreaElement)) {
          return;
        }

        let result: Token[] = tokenize(target.value);

        render(output, result);
    })
}
```

#### `tokenize` 関数を追記

```typescript:src/hashtag.ts
// 以下の仕様を実装
// - <Hashtag-Identifier> := <Start> <Continue>*
// - Start の前に Continue がない場合にのみハッシュタグとして認識
function tokenize(text: string): Token[] {
    const tokens: Token[] = [];
    let currentToken = '';
    let inHashtag = false;

    function addToken(type: TokenType, value: string) {
        tokens.push({ type, value });
    }

    for (const char of text) {
        if (Start.test(char)) {
            if (inHashtag) {
                addToken('hashtag', currentToken);
                inHashtag = false;
                currentToken = char;
            }else{
                if (currentToken.length > 0) {
                     addToken('nonHashtag', currentToken);
                }
                inHashtag = true;
                currentToken = char;
            }
        } else if (Continue.test(char)) {
            currentToken += char;
        } else {
            if (inHashtag) {
                addToken('hashtag', currentToken);
                inHashtag = false;
                currentToken = char;
            } else{
                currentToken += char;
            }
        }

    }

    // 最後の部分を処理
    if (currentToken) {
        addToken(inHashtag ? 'hashtag' : 'nonHashtag', currentToken);
    }

    return tokens;
}
```

#### `render` 関数を追記

```typescript:src/hashtag.ts
function render(element: HTMLDivElement, tokens: Token[]) {
    element.innerHTML = '';

    tokens.forEach(token => {
        if (token.type === 'hashtag') {
            const span = document.createElement('span');
            span.style.color = '#blue';
            span.textContent = token.value;
            element.appendChild(span);
        } else {
            const textNode = document.createTextNode(token.value);
            element.appendChild(textNode);
        }
    });
}
```

## 動作確認

ブラウザで textarea に `foo #bar #foo#foo #b🦰ar foo.#bar` と入力し、

<font color="blue">#bar</font> <font color="blue">#foo</font>#foo <font color="blue">#b🦰ar</font> foo.<font color="blue">#bar</font>

と表示されるか確認してください。

# おわりに

本記事で構築したハッシュタグ機能は、基本的な機能を実装していますが、まだ実装していない機能があります。それは、ハッシュタグの **`NFKC_CF` 形式**への変換です。

仕様では、比較やマッチングは **`NFKC_CF` 形式**へ変換した後に行う必要があると記述されています。例えば、`#MötleyCrüe` は `#MÖTLEYCRÜE` と一致する必要があります。

今回は色付けの機能のみを実装しましたが、文字を正規化することでより検索機能を強化することができます。