# 概要
[[Obsidian Sample Plugin]]を使って[[Obsidianプラグイン]]を作り始めるチュートリアル。
## 利用技術
- [[TypeScript]]
- [[Obsidian API]]
> [!caution]
> [[Obsidian API]]はearly alpha版のため破壊的変更に対応すること
## 確認環境
- [[Windows 11]]
- [[Node.js]] v18.12.0
- [[Obsidian]] v1.3.7
# 開発準備
## プロジェクト作成
[[Obsidian Sample Plugin]]をテンプレートしてリポジトリを作成する。
![[Pasted image 20230702140701.png]]
作成したリポジトリをクローンする。
> [!caution]
> クローンは[[Vault]]配下の`.obsidian/plugins/<your-plugin-name>`にしておくと動作確認が楽だが、[[Obsidian Sync]]でプラグインを同期している場合は、`node_modules`が同期されてしまうためオススメしない。
> [!caution]
> 2022-10-26時点でのテンプレートは、[[Rollup]]でなく[[esbuild]]が使用されている。そのため、本稿も[[esbuild]]の前提で記載しなおしている。[[Rollup]]を使っていたときのテンプレートを利用しており[[esbuild]]に移行したい場合は、[[📜2022-09-11 Obsidianプラグイン開発のビルドツールをrollupからesbuildに移行する]] が参考になるはず。
### [[manifest.json (Obsidian)|manifest.json]]の設定
以下の設定を変更する。
| プロパティ | 個人的な推奨値 |
| ------------- | -------------------------------- |
| id | リポジトリのslugと同じ |
| name | プラグイン名 |
| version | 0.0.0 |
| minAppVersion | [[Obsidian API]]の最新バージョン |
| description | プラグインの説明 |
| isDesktopOnly | モバイル非対応なら`true` |
```json
{
"id": "obsidian-free-writing",
"name": "Free Writing",
"version": "0.0.0",
"minAppVersion": "1.2.8",
"description": "TODO",
"author": "tadashi-aikawa",
"isDesktopOnly": false
}
```
> [!info] descriptionについて
> [Publishにあたってのガイド](https://docs.obsidian.md/Plugins/Releasing/Submission+requirements+for+plugins#Keep+plugin+descriptions+short+and+simple) に従わないと、[[コミュニティプラグイン]]のプルリクエストを通過しない。特に以下の点に注意すること。
>
> - [[📚Obsidian documentation Style guide]]に準拠する
> - `"This is a plugin"`のような自明な説明をしない
> - ピリオド(`.`)で終了する
> - 絵文字や環境依存文字を割ける
> - 固有名詞や商標、頭字語には正しく大文字を使用する
### [[package.json]]の設定
`name`, `version`, `description`を[[manifest.json (Obsidian)|manifest.json]]にあわせて設定。
```json
{
"name": "obsidian-free-writing",
"version": "0.0.0",
"description": "TODO",
}
```
### versionsの設定
[[versions.json]]を一旦空にする。リリース時に自動で追加されれば良い。
```json
{}
```
### [[Prettier]]のインストール
> [!info] この設定は任意
[[Prettier]]派なので`.editorconfig`を削除する。
```console
$ rm .editorconfig
```
[[フォーマッター]]として[[Prettier]]をインストール。
```console
npm i -D prettier
```
[[.prettierrc]]を作成。
```json
{
"endOfLine": "lf"
}
```
### [[tsconfig.json]]の設定
> [!info] この設定は任意
テンプレートのデフォルトでは[[strict]]が`false`になっているので`true`にする。そうしないとNull安全なコードが書けず、プルリクエストの差し戻しも増える。
[[Parameter Properties]]を使いたい場合はエラーになるので、[[strictPropertyInitialization]]を別途`false`にしておく。
```diff
{
"compilerOptions": {
+ "strict": true,
- "strictNullChecks": true,
+ "strictPropertyInitialization": false,
},
}
```
### [[ESLint]]の削除
> [!info] この設定は任意
[[ESLint]]は不要なので削除。
```console
npm uninstall @typescript-eslint/eslint-plugin @typescript-eslint/parser
rm .eslint*
```
### 依存パッケージインストール
Cloneしたらpackageのインストール。
```console
$ npm i
```
## ビルド
`main.ts`からエントリポイントとなる`main.js`をビルドする。
```console
npm run dev
```
watchが有効になっているため変更待ちになるが、一旦強制終了する。`main.js`が生成されていればOK。
## プラグインの反映
`main.js`と[[manifest.json (Obsidian)|manifest.json]]がプラグイン本体となるので、それを[[Obsidian]]で読み込み利用できるようにする。
### プラグインの移動
[[Vaultルート]]を開き、`.obsidian/plugins`配下に同名のディレクトリを作成する。
```console
cd ${your vault root}
cd .obsidian/plugins
mkdir obsidian-free-writing
```
`obsidian-free-writing`ディレクトリ配下には、[[Obsidianプラグインに必要なファイル]]を格納する必要がある。先ほど生成した`main.js`をここにコピーする。
```console
cp main.js ${your vault root}/.obsidian/plugins/obsidian-free-writing/
cp manifest.json ${your vault root}/.obsidian/plugins/obsidian-free-writing/
```
上記 `obsidian-free-writing` のことを **以後は『プラグインディレクトリ』と呼ぶ**。
### 動作確認
[[Obsidian]]を再起動して設定から今回作成するプラグインを有効にする。
![[Pasted image 20230702150101.png]]
`Free Writing`のコマンドが表示され、sample modalが表示されればOK。
![[Pasted image 20230702150237.png]]
## ホットリロード
> [!info] この設定は任意
通常は`main.js`などの成果物を更新しても、[[Obsidian]]を再起動しなければプラグインは再読み込みされない。これでは作業効率が下がるため、[[📘Obsidianプラグイン開発で自動リロードさせる]]ことを推奨する。
<div class="link-card">
<div class="link-card-header">
<img src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" class="link-card-site-icon"/>
<span class="link-card-site-name">minerva.mamansoft.net</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">📘Obsidianプラグイン開発で自動リロードさせる</p>
</div>
<div class="link-card-description">
Obsidianプラグインを開発しているとき、コードベースに変更があったら自動でObsidianをリロードする仕組みを整えました。
</div>
</div>
<img src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/%F0%9F%93%98Articles/attachments/2021-09-20.jpg" class="link-card-image" />
</div>
<a
class="internal-link"
data-href="📘Obsidianプラグイン開発で自動リロードさせる"
></a>
</div>
%%[[📘Obsidianプラグイン開発で自動リロードさせる]]%%
仕組みは上記の記事を参照してもらうとして、実際に行う作業は以下のとおり。
[[watchexec]]と[[Hot Reload (Obsidian)|Hot Reload]]をインストールしていなければインストールする。
プラグインディレクトリに空の `.hotreload` を作成する。
```console
touch ${your vault root}/.obsidian/plugins/obsidian-free-writing/.hotreload
```
リポジトリディレクトリ配下に[[Taskfile.yml]]を作成する。以下は一例。
```yaml
version: "3"
tasks:
default:
- task: help
help:
silent: true
cmds:
- task -l
build:dev: npm run dev
build: npm run build
watch: watchexec --no-vcs-ignore --exts "js,json,css" cp main.js styles.css manifest.json $USERPROFILE/work/minerva/.obsidian/plugins/obsidian-free-writing/
dev:
desc: Build and copy files when they are updated.
deps:
- build:dev
- watch
```
起動する。
```console
task dev
```
右上にリロードした旨のトーストが表示されればOK。
![[Pasted image 20230702151117.png]]
## コミット前に[[TypeScript]]の型チェックをする
> [!info] この設定は任意
[[esbuildはTypeScriptの型チェックをしない]]ので、[[📕huskyでコミット前に自動でTypeScriptの型チェックとテスト実行]]する仕組みを作った方がいい。
<div class="link-card">
<div class="link-card-header">
<img src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/favicon-64.png" class="link-card-site-icon"/>
<span class="link-card-site-name">minerva.mamansoft.net</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">📕huskyでコミット前に自動でTypeScriptの型チェックとテスト実行</p>
</div>
</div>
<img src="https://publish-01.obsidian.md/access/35d05cd1bf5cc500e11cc8ba57daaf88/Notes/attachments/minerva-image.webp" class="link-card-image" />
</div>
<a
class="internal-link"
data-href="📕huskyでコミット前に自動でTypeScriptの型チェックとテスト実行"
></a>
</div>
%%[[📕huskyでコミット前に自動でTypeScriptの型チェックとテスト実行]]%%
# ベースコード変更
> [!info] この設定は任意
デフォルトのコードはチュートリアルな側面もあるため、無駄な部分をそぎ落とす。既存コードを削除。
```console
rm main.ts main.js
```
`src/settings.ts`を作成。設定はこの中で完結させる。
```ts
import { App, PluginSettingTab, Setting } from "obsidian";
import type FreeWritingPlugin from "./main";
export interface Settings {
hoge: string;
}
export const DEFAULT_SETTINGS: Settings = {
hoge: "",
};
export class FreeWritingSettingTab extends PluginSettingTab {
plugin: FreeWritingPlugin;
constructor(app: App, plugin: FreeWritingPlugin) {
super(app, plugin);
this.plugin = plugin;
}
display(): void {
const { containerEl } = this;
containerEl.empty();
new Setting(containerEl).setName("Hoge").addText((text) =>
text
.setPlaceholder("ex: hoge.md")
.setValue(this.plugin.settings.hoge)
.onChange(async (value) => {
this.plugin.settings.hoge = value;
await this.plugin.saveSettings();
})
);
}
}
```
`src/app-helper.ts`を作成。`App`のwrapperで使いやすい独自IFと処理を定義する。実際はもっとIFや[[メソッド (TypeScript)|メソッド]]がある。
```ts
import { App, Editor, MarkdownView, TFile } from "obsidian";
interface UnsafeAppInterface {
commands: {
commands: { [commandId: string]: any };
executeCommandById(commandId: string): boolean;
};
}
export class AppHelper {
private unsafeApp: App & UnsafeAppInterface;
constructor(app: App) {
this.unsafeApp = app as any;
}
async loadFile(path: string): Promise<string> {
return this.unsafeApp.vault.adapter.read(path);
}
getActiveFile(): TFile | null {
// noinspection TailRecursionJS
return this.unsafeApp.workspace.getActiveFile();
}
getActiveMarkdownView(): MarkdownView | null {
return this.unsafeApp.workspace.getActiveViewOfType(MarkdownView);
}
getActiveMarkdownEditor(): Editor | null {
return this.getActiveMarkdownView()?.editor ?? null;
}
}
```
コマンドは `src/commands.ts` に集約する。
```ts
import { Command } from "obsidian";
import { Settings } from "./settings";
import { AppHelper } from "./app-helper";
export function createCommands(
appHelper: AppHelper,
settings: Settings
): Command[] {
return [
{
id: "main-command",
name: "Main command",
checkCallback: (checking: boolean) => {
if (appHelper.getActiveFile() && appHelper.getActiveMarkdownView()) {
if (!checking) {
// TODO:
}
return true;
}
},
},
];
}
```
最後に`src/main.ts`を作成。
```ts
import { Plugin } from "obsidian";
import { DEFAULT_SETTINGS, Settings, FreeWritingSettingTab } from "./settings";
import { AppHelper } from "./app-helper";
import { createCommands } from "./commands";
export default class FreeWritingPlugin extends Plugin {
settings: Settings;
appHelper: AppHelper;
async onload() {
await this.loadSettings();
this.appHelper = new AppHelper(this.app);
this.init();
createCommands(this.appHelper, this.settings).forEach((c) =>
this.addCommand(c)
);
this.addSettingTab(new FreeWritingSettingTab(this.app, this));
}
private init() {
// UIなどの登録系はここ
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());
}
async saveSettings() {
await this.saveData(this.settings);
this.init();
}
}
```
エントリポイントが変わったので`esbuild.config.mjs`に変更が必要。
```diff
const context = await esbuild.context({
banner: {
js: banner,
},
- entryPoints: ["main.ts"],
+ entryPoints: ["src/main.ts"],
```
# テスト
> [!info] この設定は任意
[[Obsidianプラグイン開発でテストコード]]を書く準備をする。その後に `src/hoge.test.ts` を作って動作することを確認する。
```ts
import { describe, expect, test } from "@jest/globals";
describe("from", () => {
describe.each<{
a: number;
b: number;
expected: number;
}>`
a | b | expected
${1} | ${2} | ${3}
`("test", ({ a, b, expected }) => {
test(`test(${a}, ${b}, ${expected})`, () => {
const actual = a + b;
expect(actual).toBe(expected);
});
});
});
```
[[package.json]]に`scripts.test`を追加。
```diff
"scripts": {
+ "test": "jest",
}
```
テストが実行されればOK。
```console
$ npm run test
>
[email protected] test
> jest
PASS src/hoge.test.ts
from
test
√ test(1, 2, 3) (1 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 1.288 s
Ran all test suites.
```
[[Taskfile.yml]]にも`test`を追加しておく。
```yaml
version: "3"
tasks:
default:
- task: help
help:
silent: true
cmds:
- task -l
build:dev: npm run dev
build: npm run build
test: npm test
watch: watchexec --no-vcs-ignore --exts "js,json,css" cp main.js styles.css manifest.json $USERPROFILE/work/minerva/.obsidian/plugins/obsidian-embedded-code-title/
dev:
desc: Build and copy files when they are updated.
deps:
- build:dev
- watch
```
# UIライブラリの使用
> [!info] この設定は任意
[[React]]や[[Svelte]]を使ってUIを開発したい場合。
### [[React]]
以下を参照。
<div class="link-card">
<div class="link-card-header">
<img src="https://obsidian.md/favicon.ico" class="link-card-site-icon"/>
<span class="link-card-site-name">marcus.se.net</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">React | Obsidian Plugin Developer Docs</p>
</div>
<div class="link-card-description">
In this guide, you'll configure your plugin to use React. It assumes that you already have a plugin ...
</div>
</div>
</div>
<a href="https://marcus.se.net/obsidian-plugin-docs/getting-started/react"></a>
</div>
[[📜Obsidianプラグイン開発でReactを導入]] も参考に。
### [[Svelte]]
以下を参照。
<div class="link-card">
<div class="link-card-header">
<img src="https://obsidian.md/favicon.ico" class="link-card-site-icon"/>
<span class="link-card-site-name">marcus.se.net</span>
</div>
<div class="link-card-body">
<div class="link-card-content">
<div>
<p class="link-card-title">Svelte | Obsidian Plugin Developer Docs</p>
</div>
<div class="link-card-description">
This guide explains how to configure your plugin to use Svelte, a light-weight alternative to tradit...
</div>
</div>
</div>
<a href="https://marcus.se.net/obsidian-plugin-docs/getting-started/svelte"></a>
</div>
[[📜2023-01-29 SilhouetteにSvelteを導入する]] も参考に。
# リリース
## リリースに必要な作業と内訳
リリースに必要な作業と、それをどこで実行するかは以下のとおり。
| 作業 | どこで |
| ------------------------------- | ------------------- |
| バージョンのタグ付け | `npm version` |
| `manifest.json`のバージョン更新 | `versions-bump.mjs` |
| リリースビルド | [[GitHub Actions]] |
| 成果物のアップロード | [[GitHub Actions]] |
## 準備/確認
### バージョンprefixの制御
> [!info] デフォルトで設定されているので、変更の必要はない。
バージョンにvのprefixをつけてはいけないので、[[.npmrc]]ファイルでそのように設定されている必要がある。
```
tag-version-prefix=""
```
### バージョンアップ用の[[JavaScript]]ファイル編集
> [!info] このscriptは任意。リリースのたびに手動設定でも良い。
[[クロスプラットフォーム]]環境でリリースできるようにするため、[[CLI]]のコマンドに極力依存しないよう[[JavaScript]]ファイルが用意されている。これを少し変更して以下のようにする。
`version-bump.mjs`
```js
import { readFileSync, writeFileSync } from "fs";
const targetVersion = process.env.npm_package_version;
function updateVersion(beta) {
const manifestBeta = JSON.parse(readFileSync("manifest-beta.json", "utf8"));
manifestBeta.version = targetVersion;
writeFileSync("manifest-beta.json", JSON.stringify(manifestBeta, null, " "));
if (beta) {
return;
}
const manifest = JSON.parse(readFileSync("manifest.json", "utf8"));
const { minAppVersion } = manifest;
manifest.version = targetVersion;
writeFileSync("manifest.json", JSON.stringify(manifest, null, " "));
// update versions.json with target version and minAppVersion from manifest.json
let versions = JSON.parse(readFileSync("versions.json", "utf8"));
versions[targetVersion] = minAppVersion;
writeFileSync("versions.json", JSON.stringify(versions, null, " "));
}
updateVersion(targetVersion.includes("beta"));
```
バージョンに`beta`の文字列を含む場合のみベータバージョンとして、それ以外の場合は正式バージョンとして各ファイルのバージョンインクリメントを行う。
> [!caution]
> ベータリリース機能を使うには `manifest-beta.json` が存在するという前提が必要。ベータリリースが不要なら、もともとテンプレートで用意されていたコードをそのまま使えばよい。
### バージョンアップのnpmコマンド追加
> [!info] このscriptは任意。リリースのたびに手動設定でも良い。
`npm version`コマンド実行時に併せて実行されるよう[[package.json]]に追加。
```json
"scripts": {
"version": "node version-bump.mjs && git add manifest-beta.json manifest.json versions.json"
}
```
> [!caution]
> ベータリリース機能が不要なら `manifest-beta.json` はコマンドから削除する。
### [[GitHub Actions]]の設定
タグがpushされたら自動で成果物をビルドし、[[GitHub]]でリリースするためのAction。
`.github/workflows/release.yaml`を作成する。
```yaml
name: "Release"
on:
push:
tags:
- "*"
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- run: npm install
- run: npm run build
- name: Create Release
id: create_release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
draft: true
files: |
main.js
styles.css
manifest.json
manifest-beta.json
```
> [!caution]
> リリース以外でもタグを使う場合は、`on.push.tags[]`の設定を変更する必要がある。
> [!caution]
> ベータリリース機能が不要なら `manifest-beta.json` は `files` から削除する。
### タスクの設定
> [!info] このscriptは任意。リリースのたびに手動実行でも良い。
リリースに必要な作業を1コマンドで実行できるよう[[Taskfile.yml]]を記載する。
```yaml
release:
desc: |
Build
∟ [Ex] task release VERSION=1.2.3
∟ [Ex] task release VERSION=1.2.3-beta
deps:
- ci
cmds:
- npm version {{.VERSION}}
- task: ci
- git push --tags
- git push
preconditions:
- sh: "[ {{.VERSION}} != '' ]"
msg: "VERSION is required."
```
このコマンドはバージョンのインクリメントと、テスト/リリース可能なことの確認、および[[Git]]やタグの情報更新をしているだけ。`task release`コマンドを実行してもリリース物はアップロードされない。それは`git push`をトリガーに[[GitHub Actions]]で自動的に実施する。
> [!hint]- `task: ci`について
> `task: ci`と関連コマンドの定義は以下の通り。リリース前に確認が不要なら必要ない。
> ```yaml
> init:
> desc: Install dependencies
> cmds:
> - npm install
> build: npm run build
> test: npm test
>
> ci:
> desc: For CI
> cmds:
> - task: init
> - task: build
> - task: test
> ```
## リリースする
リリースコマンドを実行するだけ。
```console
task release VERSION=0.1.0
```
## 公開
[[Obsidian Pluginをコミュニティプラグインとして公開]]する。
# 参考
- [[ObsidianプラグインやTemplaterでCORS制限を回避する方法]]