メインコンテンツにスキップ

3件の投稿件の投稿が「obsidian」タグ付き

すべてのタグを見る

ブログ検索露出のための画像最適化

· 7分の読み時間
Haril Song
Owner, Software Engineer at 42dot

ブログ投稿の自動化プロセスにおいて、SEOのための画像最適化について議論します。これは成功の話ではなく、むしろ失敗の話であり、最終的にはプランBに頼ることになりました。

情報

コードはGitHubで確認できます。

問題の特定

SEO最適化のためには、ブログ投稿内の画像をできるだけ小さくするのが最善です。これにより、検索エンジンのクロールボットの効率が向上し、ページの読み込み速度が速くなり、ユーザーエクスペリエンスにも良い影響を与えます。

では、どの画像フォーマットを使用すべきでしょうか? 🤔

Googleはこの問題に対処するためにWebPという画像フォーマットを開発し、その使用を積極的に推奨しています。広告から利益を得るGoogleにとって、画像の最適化はユーザーがウェブサイトの広告に迅速にアクセスできるようにするため、利益に直結します。

実際、約2.8MBのjpgファイルをwebpに変換すると、約47kbに減少しました。これは50分の1以上の削減です! 多少の画質の低下はありましたが、ウェブページ上ではほとんど気になりませんでした。

image

このレベルの改善があれば、問題を解決するモチベーションは十分です。実装するための情報を集めましょう。

解決へのアプローチ

プランA. O2への機能追加

ブログ投稿のために開発したプラグインO2があります。このプラグインの機能の一部としてWebP変換タスクを含めるのが最も理想的だと考え、まずこのアプローチを試みました。

画像処理で最も有名なライブラリはsharpですが、これはOS依存であり、Obsidianプラグインでは使用できません。これを確認するためにObsidianコミュニティで質問したところ、使用できないという明確な回答を得ました。

image

image

image

関連するコミュニティの会話

sharpが使用できないため、代替としてimageminを使用することにしました。

しかし、重大な問題がありました:imageminはesbuildを実行する際にプラットフォームがnodeであることを要求しますが、Obsidianプラグインはプラットフォームがブラウザであることを要求します。両方のプラットフォームで動作するはずのneutralに設定しても、どちらでも動作しませんでした...

image

O2に適用できる適切なライブラリをすぐに見つけることができなかったため、フォーマット変換タスクを処理するための簡単なスクリプトを実装することにしました。

プランB. npmスクリプト

プラグインに機能を追加する代わりに、Jekyllプロジェクト内で直接スクリプトを使ってフォーマットを簡単に変換できます。

async function deleteFilesInDirectory(dir) {
const files = fs.readdirSync(dir);

files.forEach(function (file) {
const filePath = path.join(dir, file);
const extname = path.extname(filePath);
if (extname === '.png' || extname === '.jpg' || extname === '.jpeg') {
fs.unlinkSync(filePath);
console.log(`remove ${filePath}`);
}
});
}

async function convertImages(dir) {
const subDirs = fs
.readdirSync(dir)
.filter((file) => fs.statSync(path.join(dir, file)).isDirectory());

await imagemin([`${dir}/*.{png,jpg,jpeg}`], {
destination: dir,
plugins: [imageminWebp({quality: 75})]
});
await deleteFilesInDirectory(dir);

for (const subDir of subDirs) {
const subDirPath = path.join(dir, subDir);
await convertImages(subDirPath);
}
}

(async () => {
await convertImages('assets/img');
})();

この方法では、望む機能を迅速に実装できますが、ユーザーがO2の制御外で変更された画像を手動でマークダウン文書に再リンクする必要があります。

この方法を使用する場合、正規表現を使用してすべてのファイルにリンクされた画像の拡張子をwebpに変更し、文書内で画像を再リンクするタスクをスキップすることにしました。

// 省略
async function updateMarkdownFile(dir) {
const files = fs.readdirSync(dir);

files.forEach(function (file) {
const filePath = path.join(dir, file);
const extname = path.extname(filePath);
if (extname === '.md') {
const data = fs.readFileSync(filePath, 'utf-8');
const newData = data.replace(
/(!\^\*]\((.*?)\.(png|jpg|jpeg)\))/g,
(match, p1, p2, p3) => {
return p1.replace(`${p2}.${p3}`, `${p2}.webp`);
}
);
fs.writeFileSync(filePath, newData);
}
});
}

(async () => {
await convertImages('assets/img');
await updateMarkdownFile('_posts');
})();

次に、ブログ投稿を公開する際に実行するスクリプトを書きました。

#!/usr/bin/env bash

echo "画像最適化中...🖼️"
node tools/imagemin.js

git add .
git commit -m "post: publishing"

echo "プッシュ中...📦"
git push origin master

echo "完了! 🎉"
./tools/publish

ターミナルで直接shを実行するのはなんとなくエレガントではないと感じました。package.jsonに追加して、よりクリーンに使用できるようにしましょう。

{
"scripts": {
"publish": "./tools/publish"
}
}
npm run publish

image かなりうまくいきます。

とりあえず、これで結論を出しました。

結論

このプロセスを通じて、ブログ投稿のパイプラインは次のように変わりました:

以前

現在

結果だけを見ると、それほど悪くないように見えますね...? 🤔

画像フォーマット変換機能をO2プラグインの一部として追加したかったのですが、さまざまな理由で適用できませんでした(今のところ)。JSとshを使用する方法は、ユーザーに追加のアクションを要求し、メンテナンスが容易ではありません。この機能を内部的にO2に取り込む方法を一貫して考える必要があります。

参考文献

O2におけるデザインパターンを用いたコード生産性の向上

· 12分の読み時間
Haril Song
Owner, Software Engineer at 42dot

この記事では、O2プロジェクトの構造をデザインパターンを用いて改善し、より柔軟な管理を実現するプロセスについて説明します。

問題

開発に勤しんでいると、ある日突然Issueが提起されました。

image

Issueの内容を反映するのは難しくありませんでした。しかし、コードを掘り下げていくうちに、しばらく放置していた問題が浮上してきました。

image

以下は、以前に書かれたMarkdown構文変換コードの実装です。

警告

コードが長いため、一部抜粋しています。完全なコードはO2プラグインv1.1.1をご参照ください 🙏

export async function convertToChirpy(plugin: O2Plugin) {
try {
await backupOriginalNotes(plugin);
const markdownFiles = await renameMarkdownFile(plugin);
for (const file of markdownFiles) {
// 二重角括弧を削除
const title = file.name.replace('.md', '').replace(/\s/g, '-');
const contents = removeSquareBrackets(await plugin.app.vault.read(file));
// リソースリンクをjekyllリンクに変換
const resourceConvertedContents = convertResourceLink(plugin, title, contents);

// コールアウト
const result = convertCalloutSyntaxToChirpy(resourceConvertedContents);

await plugin.app.vault.modify(file, result);
}

await moveFilesToChirpy(plugin);
new Notice('Chirpy変換が完了しました。');
} catch (e) {
console.error(e);
new Notice('Chirpy変換に失敗しました。');
}
}

TypeScriptやObsidianの使用に不慣れだったため、全体のデザインよりも機能の実装に重点を置いていました。新しい機能を追加しようとすると、副作用を予測するのが難しく、コードの実装が開発者の意図を明確に伝えることができませんでした。

コードの流れをよりよく理解するために、現在のプロセスのグラフを作成しました。

機能を関数に分けたものの、コードは依然として手続き的に書かれており、コード行の順序が全体の動作に大きく影響していました。この状態で新しい機能を追加するには、全体の変換プロセスを壊さないように正確に実装する必要があります。新しい機能をどこに実装すればよいのか?その答えはおそらく「コードを見なければならない」でしょう。現在、ほとんどのコードが一つの大きなファイルに書かれているため、全体のコードを分析する必要があるのとほぼ同じです。オブジェクト指向の観点から言えば、単一責任の原則 (SRP) が適切に守られていないと言えます。

この状態は、どれだけ前向きに表現しても、メンテナンスが容易ではないように思えました。O2プラグインは個人的な使用のために作成されたものなので、「TSに不慣れだから」と合理化して、メンテナンスが難しいスパゲッティコードを生産することを正当化することはできませんでした。

Issueを解決する前に、まず構造を改善することにしました。

どのような構造を実装すべきか?

O2プラグインは、構文変換プラグインとして、ObsidianのMarkdown構文をさまざまな形式に変換できる必要があります。これは明確な要件です。

したがって、デザインは主に拡張性に焦点を当てるべきです。

プラットフォームロジックをモジュール化し、変換プロセスを抽象化してテンプレートのように実装する必要があります。これにより、異なるプラットフォームの構文をサポートする新しい機能を実装する際に、開発者は構文変換の小さな単位の実装に集中でき、全体のフローを再実装する必要がなくなります。

これに基づいて、デザイン要件は次のようになります:

  1. 文字列(Markdownファイルの内容)は必要に応じて順番に(または順番に)変換されるべきです。
  2. 特定の変換ロジックはスキップ可能であり、外部設定に基づいて動的に制御可能であるべきです。
  3. 新しい機能の実装は簡単であり、既存のコードに最小限の影響しか与えないべきです。

実行の順序があり、機能を追加する能力があるため、責任の連鎖パターンがこの目的に適しているように思えました。

デザインパターンの適用

プロセス->プロセス->プロセス->完了! : 責任の連鎖の要約

export interface Converter {
setNext(next: Converter): Converter;
convert(input: string): string;
}

export abstract class AbstractConverter implements Converter {
private next: Converter;

setNext(next: Converter): Converter {
this.next = next;
return next;
}

convert(input: string): string {
if (this.next) {
return this.next.convert(input);
}
return input;
}
}

Converterインターフェースは、convert(input)を通じて特定の文字列を変換する役割を果たします。setNextで次に処理するConverterを指定し、再びConverterを返すことで、メソッドチェーンを使用できます。

抽象化が行われたことで、以前は一つのファイルに実装されていた変換ロジックが、各機能に責任を持つ個々のConverter実装に分離されました。以下は、コールアウト構文変換ロジックを分離したCalloutConverterの例です。

export class CalloutConverter extends AbstractConverter {
convert(input: string): string {
const result = convertCalloutSyntaxToChirpy(input);
return super.convert(result);
}
}

function convertCalloutSyntaxToChirpy(content: string) {
function replacer(match: string, p1: string, p2: string) {
return `${p2}\n{: .prompt-${replaceKeyword(p1)}}`;
}

return content.replace(ObsidianRegex.CALLOUT, replacer);
}

現在、クラス間の関係は次のようになっています。

現在、各Converterに実装された最小単位の機能を組み合わせることで、順番に操作を行うチェーンが作成されます。これが、このパターンが責任の連鎖と呼ばれる理由です。

export async function convertToChirpy(plugin: O2Plugin) {
// ...
// 変換チェーンを作成
frontMatterConverter.setNext(bracketConverter)
.setNext(resourceLinkConverter)
.setNext(calloutConverter);

// 先頭のfrontMatterConverterに変換を依頼し、接続されたコンバータが順次動作します。
const result = frontMatterConverter.convert(await plugin.app.vault.read(file));
await plugin.app.vault.modify(file, result);
// ...
}

現在、ロジックが適切な責任に分離されているため、コードの読み取りが非常に簡単になりました。新しい機能を追加する必要がある場合は、必要なConverterを実装するだけで済みます。また、他のConverterがどのように動作するかを知る必要がなく、setNextを通じて新しい機能を追加できます。各Converterは独立して動作し、カプセル化の原則に従います。

最後に、すべてのテストが通過したことを確認し、PRを作成しました。

image

次のステップ

構造が大幅に改善されたものの、まだ一つの欠点が残っています。setNextでリンクされた構造では、正しく動作するためには最前のConverterを呼び出す必要があります。最前のConverterではなく、別のConverterを呼び出すと、意図した結果とは異なる結果になる可能性があります。例えば、NewConverterfrontMatterConverterの前に実装されているが、frontMatterConverter.convert(input)が変更されていない場合、NewConverterは適用されません。

これは開発者が注意を払う必要がある点であり、エラーの余地があるため、将来的に改善が必要な領域の一つです。例えば、Converterを含むContextのようなものを実装し、直接Converterを呼び出すことなく変換プロセスを実行する方法が考えられます。これは次のバージョンで実装する予定です。


2023-03-12 アップデート

PRのおかげで、同じ機能が継承ではなくコンポジションを使用して、より柔軟な構造で実行されました。

結論

この記事では、手続き的に書かれたモノリシックなファイルから、デザインパターンを通じて役割と責任を再分配し、よりオブジェクト指向でメンテナンスしやすいコードに改善するプロセスを説明しました。

情報

完全なコードはGitHubで確認できます。

[O2] Obsidianプラグインの開発

· 11分の読み時間
Haril Song
Owner, Software Engineer at 42dot

概要

ObsidianはMarkdownファイル間のリンクを通じてグラフビューを提供し、情報の保存とナビゲーションが便利です。しかし、これを実現するために、Obsidianは独自のシンタックスを元のMarkdownシンタックスに加えて強制します。これにより、他のプラットフォームでObsidianのMarkdownドキュメントを読む際に互換性の問題が生じることがあります。

現在、私はJekyllブログを使用して投稿しているため、Obsidianで書いた後、ブログ公開のためにシンタックスを手動で調整する必要があります。具体的なワークフローは以下の通りです:

  • ファイルリンクに[[ ]]を使用する(これはObsidianの独自シンタックス)
  • 画像ファイルを含む添付ファイルのパスをリセットする
  • title.mdyyyy-MM-dd-title.mdにリネームする
  • コールアウトシンタックス

image レイヤー境界を越える二重矢印は手動での介入が必要です。

ObsidianとJekyllを併用しているため、このシンタックス変換プロセスと添付ファイルのコピーを自動化する必要がありました。

ObsidianはNotionとは異なり、コミュニティプラグインを通じて機能拡張が可能なので、自分でプラグインを作成してみることにしました。公式ドキュメントを確認したところ、ObsidianはNodeJSをベースにしたプラグイン開発をガイドしていることがわかりました。言語の選択肢は限られていましたが、TypeScriptに興味があったので、NodeJS/TS環境をセットアップして学習を始めました。

実装プロセス

ネーミング

まず、開発の最も重要な部分に取り組みました。

思ったよりも時間はかからず、プラグインの説明「ObsidianのシンタックスをJekyllに変換する」を書いているうちに突然「O2」というプロジェクト名が思い浮かびました。

image

変換の準備

適切な名前が決まったので、次にどのファイルをどのように変換するかを決定しました。

ブログ投稿のワークフローは以下の通りです:

  1. readyというフォルダに下書きを書く。
  2. 原稿が完成したら、添付ファイルを含むファイルをJekyllプロジェクトにコピーし、その過程でObsidianシンタックスをJekyllシンタックスに適切に変換する。
  3. 原稿をreadyフォルダからpublishedに移動し、公開済みであることを示す。

このワークフローをそのままプログラムすることにしました。ただし、VScodeで開いているJekyllプロジェクトのオリジナルファイルを編集する代わりに、プラグインワークスペース内でコピーを作成して変更し、オリジナルファイルを変更せずにJekyllシンタックスに変換することにしました。

このステップを簡単にまとめると:

  1. /readyから原稿A.mdをコピーして/publishedに移動し、/published/A.mdを変更しない。
  2. /ready/A.mdのタイトルとシンタックスを変換する。
  3. /ready/yyyy-MM-dd-A.mdをJekyll公開用のパスに移動する。

では、実装を始めましょう。

オリジナルファイルのコピー

// readyフォルダ内のMarkdownファイルのみを取得
function getFilesInReady(plugin: O2Plugin): TFile[] {
return this.app.vault.getMarkdownFiles()
.filter((file: TFile) => file.path.startsWith(plugin.settings.readyDir))
}

// ファイルをpublishedフォルダにコピー
async function copyToPublishedDirectory(plugin: O2Plugin) {
const readyFiles = getFilesInReady.call(this, plugin)
readyFiles.forEach((file: TFile) => {
return this.app.vault.copy(file, file.path.replace(plugin.settings.readyDir, plugin.settings.publishedDir))
})
}

/readyフォルダ内のMarkdownファイルを取得し、file.pathpublishedDirに置き換えることで、簡単にコピーができます。

添付ファイルのコピーとパスのリセット

function convertResourceLink(plugin: O2Plugin, title: string, contents: string) {
const absolutePath = this.app.vault.adapter.getBasePath()
const resourcePath = `${plugin.settings.jekyllResourcePath}/${title}`
fs.mkdirSync(resourcePath, {recursive: true})

const relativeResourcePath = plugin.settings.jekyllRelativeResourcePath

// resourceDir/image.pngをassets/img/<title>/image.pngにコピーする前に変更
extractImageName(contents)?.forEach((resourceName) => {
fs.copyFile(
`${absolutePath}/${plugin.settings.resourceDir}/${resourceName}`,
`${resourcePath}/${resourceName}`,
(err) => {
if (err) {
new Notice(err.message)
}
}
)
})
// シンタックス変換
return contents.replace(ObsidianRegex.IMAGE_LINK, `![image](/${relativeResourcePath}/${title}/$1)`)
}

添付ファイルはボルト外に移動する必要があり、これはObsidianのデフォルトAPIでは実現できません。そのため、fsを使用して直接ファイルシステムにアクセスする必要があります。

情報

ファイルシステムへの直接アクセスはモバイルでの使用が難しいことを意味するため、Obsidianの公式ドキュメントでは、そのような場合にmanifest.jsonisDesktopOnlytrueに指定することをガイドしています。

MarkdownファイルをJekyllプロジェクトに移動する前に、Obsidianの画像リンクシンタックスを解析して画像ファイル名を特定し、それらをJekyllのresourceフォルダに移動してMarkdownデフォルトの画像リンクが正しく変換されるようにします。これにより、添付ファイルが見つかるようになります。

コールアウトシンタックスの変換

Obsidianのコールアウト

> [!NOTE] callout title
> callout contents

サポートされるキーワード: tip, info, note, warning, danger, errorなど。

Jekyll chirpyのコールアウト

> callout contents
{: .promt-info}

サポートされるキーワード: tip, info, warning, danger

シンタックスが異なるため、この部分を置換するために正規表現を使用し、リプレーサーを実装する必要があります。

export function convertCalloutSyntaxToChirpy(content: string) {
function replacer(match: string, p1: string, p2: string) {
if (p1.toLowerCase() === 'note') {
p1 = 'info'
}
if (p1.toLowerCase() === 'error') {
p1 = 'danger'
}
return `${p2}\n{: .prompt-${p1.toLowerCase()}}`
}

return content.replace(ObsidianRegex.CALLOUT, replacer)
}

Jekyllでサポートされていないキーワードは、類似の役割を持つ他のキーワードに変換されます。

完了したファイルの移動

現在使用しているJekyllベースのブログには、投稿を公開するために特定のパスに配置する必要があります。Jekyllプロジェクトの場所はクライアントごとに異なる可能性があるため、カスタムパスの処理が必要です。設定タブを通じてこれを設定することにし、以下のような入力フォームを作成しました。

image

すべての変換が完了したら、Jekyllの_postパスにファイルを移動することで変換プロセスが完了します。

async function moveFilesToChirpy(plugin: O2Plugin) {
// ボルト外のファイルを移動するためには絶対パスが必要
const absolutePath = this.app.vault.adapter.getBasePath()
const sourceFolderPath = `${absolutePath}/${plugin.settings.readyDir}`
const targetFolderPath = plugin.settings.targetPath()

fs.readdir(sourceFolderPath, (err, files) => {
if (err) throw err

files.forEach((filename) => {
const sourceFilePath = path.join(sourceFolderPath, filename)
const targetFilePath = path.join(targetFolderPath, filename)

fs.rename(sourceFilePath, targetFilePath, (err) => {
if (err) {
console.error(err)
new Notice(err.message)
throw err
}
})
})
})
}

正規表現

export namespace ObsidianRegex {
export const IMAGE_LINK = /!\[\[(.*?)]]/g
export const DOCUMENT_LINK = /(?<!!)\[\[(.*?)]]/g
export const CALLOUT = /> \[!(NOTE|WARNING|ERROR|TIP|INFO|DANGER)].*?\n(>.*)/ig
}

Obsidian独自の特別なシンタックスは、正規表現を使用して解析しました。グループを使用することで、特定の部分を抽出して変換することができ、プロセスが便利になりました。

コミュニティプラグインリリースのためのPR作成

最後に、コミュニティプラグインリポジトリにプラグインを登録するために、PRを作成して締めくくります。コミュニティガイドラインに従わないとPRが拒否される可能性があるため、Obsidianはプラグイン開発時に注意すべき点をガイドしているので、これらのガイドラインにできるだけ従うことが重要です。

image

過去のPRに基づくと、マージには約2〜4週間かかるようです。後でフィードバックがあれば、必要な調整を行い、マージを待ちます。

結論

「これは簡単な作業で、3日で終わるだろう」と思っていましたが、海外旅行中にプラグインを実装しようとしたため、リリースPRの作成を含めて約1週間かかりました😂

image JUnitを開発したKent BeckとErich Gammaも飛行機でこんな風にコーディングしていたのだろうか...

JavaやKotlinからTypeScriptに切り替えるのは難しく、慣れていなかったため、書いているコードがベストプラクティスかどうか自信がありませんでした。しかし、このおかげでasync-awaitのようなJSシンタックスを詳しく掘り下げることができ、新しい技術スタックを自分のレパートリーに追加することができました。これは誇らしい気持ちです。また、新しいトピックを書く機会も得られました。

最も良い点は、ブログ投稿にほとんど手作業が必要なくなったことです!プラグインでシンタックスを変換した後、スペルチェックを行ってGitHubにプッシュするだけです。もちろん、まだ多くのバグがありますが...

今後は、プラグインのアンチパターンを排除し、モジュールをよりクリーンにするためにTypeScriptの学習を続けていく予定です。

同じようなジレンマに直面している方は、プロジェクトに貢献したり、他の方法で協力して一緒に構築するのも良いでしょう!いつでも歓迎です😄

情報

完全なコードはGitHubで確認できます。

次のステップ 🤔

  • 軽微なバグの修正
  • 脚注シンタックスのサポート
  • 画像リサイズシンタックスのサポート
  • 変換中にエラーが発生した場合のロールバック処理の実装
  • 他のモジュールを追加するための処理の抽象化

リリース 🚀

約6日間のコードレビューの後、PRがマージされました。プラグインは現在、Obsidianコミュニティプラグインリポジトリで利用可能です。🎉

image

参考