Пишем плагины для Obsidian. Часть 1
Вступление
После шумихи с Notion все ринулись кто куда, но так сложилось, что по большей части все стали смотреть в сторону Obsidian. И Хабр заполонили статьи про Obsidian и про плагины для Obsidian.
Правда, к моему сожалению на техническом ресурсе Хабр я почему-то не нашел ни одной статьи о том, как написать свой собственный плагин, а не как использовать кем-то созданные решения. Я считаю это либо досадным недоразумением либо своим неумением использовать поиск Хабра. Накидайте мне в комментарии, если я не прав, я приму к сведению.
А сейчас же я хотел бы заполнить сложившийся вакуум собственным гайдом.
Что мы будем делать
Это будет цикл из двух статей, в котором мы напишем с вами целых четыре плагина. Они будут несложными, маленькими, но я без шуток планирую пользоваться ими лично на постоянной основе в своем основном Obsidian-хранилище. Т.е. я пишу их в первую очередь для себя, а это гарантирует, что сами плагины, пусть и простые, но не высосаны из пальца и не являются бесполезными Hello World’ами, а выполняют полезную работу как минимум для одного пользователя.
Статьи предназначены для максимально легкого вкатывания в плагинописание для Obsidian. Я хочу показать, насколько это просто, и что можно вот так вот сесть и начать писать плагин, имея на руках только Obsidian и ваш любимый текстовый редактор. Больше ничего ставить не придется, я вам гарантирую это.
Так же я предлагаю вам пользоваться этими статьями, как вспомогательной документацией вида do-by-example, когда вам самим понадобится написать свой плагин. Мои четыре небольших плагина могут делать совершенно не то, что вы будете делать в своем плагине, но вы сможете увереннее ориентироваться в документации и знать, куда стучаться.
Небольшая просьба: если будете читать статью по диагонали, задержитесь, пожалуйста, на голосовании в конце статьи — мне очень интересна статистика.
Что нужно, чтобы написать плагин
Я долго не решался даже смотреть в сторону собственных плагинов, потому что думал, что для их написания необходим фронтенд-зоопарк из TypeScript, Electron, npm, Node.js или чего-то еще подобного.
Официально документация Obsidian настоятельно рекомендует использовать TypeScript и npm и использует только такой подход в своем GitHub с примерами. Я же в свою очередь утверждаю, что если вы хотите просто сесть и написать малюсенький плагин, у вас нет необходимости даже в этом стеке.
Четыре плагина которые мы напишем — это история про то как написать main.js
к каждому из них. Ну, еще manifest.json
, но там совсем пара строчек.
Краткий обзор API
У Obsidian есть официальная документация по разработке плагинов. Она содержит, как гайды по разным тематикам, так и полноценный API Reference, где перечислены все классы, доступные нам для разработки.
От себя могу сказать, что гайды достаточно поверхностные и вводят только в самый-самый курс дела, не вдаваясь куда-то глубоко. Рыться же в API Reference самостоятельно тоже порой бывает проблематично, когда сам недостаточно уверен в том, что ищешь. На худой конец у Obsidian есть Discord-канал, где комьюнити сможет помочь вам в чате plugin-dev
.
В начале погружения очень тяжко без какого-то базового референса по самым важным классам API, чтобы понимать, куда идти, чтобы сделать конкретное действие. Вот мой список таких классов:
Важные логические классы:
Plugin
. От него наследуются, чтобы создать плагинApp
. Это точка сбора всех крупных, значимых синглтонов, крутящихся в ObsidianVault
для работы с папками и файлами хранилищаFileManager
для более узкоспециализированной работы с папками и файлами хранилища. Иногда не угадаешь, где находится нужный тебе функционал: вVault
илиFileManager
Важные UI-классы:
Notice
. Для мелких popup-сообщений пользователюModal
. Диалоговое окно, в т.ч. с возможностью ввода данныхItemView
. Кастомный гуй, куда можно рендерить любой HTML-контентWorkspace
. Класс, заведующий всеми view на экране. Все вкладки, сплиты и прочее управляются черезWorkspace
Забавный JS-момент: в трех плагинах из четырех нам понадобится вот эта незатейливая функция:
const removePrefix = (value, prefix) =>
value.startsWith(prefix) ? value.slice(prefix.length) : value;
Да, по странным стечениям обстоятельств написание плагинов для Obsidian часто требует удалять префикс из строки, а интерфейс String
не может предложить нам ничего подходящего ¯\_(ツ)_/¯.
Скелет плагина
В самом минималистичном варианте, чтобы написать плагин вам нужно:
В вашем, желательно тестовом, Obsidian-vault’е найти папку
.obsidian/plugins
Создать в этой папке папку с именем вашего плагина
Разместить в папке плагина файл
manifest.json
с информацией о плагинеРазместить в папке плагина файл
main.js
с кодом плагина
На самом деле как бы и все:) Плагин сразу подхватится Obsidian, и его можно будет включить в настройках как обычный Community plugin.
Сперва всегда создаем файл manifest.json
, где декларируем всю информацию о плагине:
{
"id": "test-habr-plugin",
"name": "Test Habr Plugin",
"version": "1.0.1",
"minAppVersion": "1.0.0",
"description": "Habr stronk",
"author": "askepit",
"authorUrl": "",
"helpUrl": "",
"isDesktopOnly": false
}
Я думаю, даже нет смысла описывать, что здесь происходит — все поля имеют хорошие говорящие названия. Вся указанная в манифесте информация будет использована Obsidian для отображения информации о вашем плагине в разделе настроек Community plugins.
Главное, не повторяйте мою ошибку! Я по глупости сперва указал в моих плагинах "isDesktopOnly": true
будучи уверенным, что на мобильные устройства задеплоить рукописные плагины сложно, и я не буду этим заниматься. Спойлер — деплой на mobile не стоит практически ничего. При условии, конечно, что вы заблаговременно выставите "isDesktopOnly": false
. Я же за свою оплошность поплатился долгим тупняком и даже просьбами о помощи в официальном Discord-канале, поскольку напрочь забыл про это манифест-поле. В общем, не повторяйте мою ошибку :)
Теперь приступаем непосредственно к коду плагина. В своем минимальном варианте работающий, распознаваемый Obsidian плагин выглядит следующим образом:
'use strict'
var obsidian = require('obsidian')
class TestHabrPlugin extends obsidian.Plugin {
async onload() {
}
}
module.exports = TestHabrPlugin
Эта болванку вы можете смело копировать из одного своего плагина в другой. Класс, который мы отнаследовали от obsidian.Plugin
— наша основная сущность, управляющая всем жизненным циклом нашего плагина.
Плагин ничего не делает, но он распознается в ваших настройках, и его можно будет включить. А если сделать вот так:
async onload() {
console.log('Habr stronk')
}
то при включении плагина в dev-tools консоль Обсидиана (Ctrl + Shift + I
) мы увидим наше сообщение:
Плагин 1. Guitar tabs viewer
Итак, начинаем с самого простого и незначительного плагин для разминки и легкого вкатывания.
Задача
У меня есть записи с гитарными табулатурами, которые я храню в таком виде:
E|---------------------------------|
B|---------------------------------|
G|---------------------------------|
D|-------3-5-4-3-------------------|
A|-5---5---------5-3---3-0-0-3-3-4-|
E|---------------------------------|
Табулатуры, как правило, выглядят плюс-минус одинаково, но бывают странности, когда кто-то в интернете не прав рисует линейку струн на какой-то свой манер, и можно наткнуться табулатуру с такими горизонтальными линиями:
E|—————————————————————————————————|
B|—————————————————————————————————|
G|—————————————————————————————————|
D|———————3—5—4—3———————————————————|
A|—5———5—————————5—3———3—0—0—3—3—4—|
E|—————————————————————————————————|
Бывают и другие линии — ASCII-символов для горизонтальных черточек предостаточно. Меня порядочно коробит этот визуальный раздрай. К тому же хочется как-то минимизировать само присутствие линий на табулатурном стане.
Поэтому я хочу, чтобы view-режим страницы с табулатурами приводил все известные мне виды тире к единому знаменателю, и в качестве этого знаменателя я выбрал символ Middle dot ·
, как визуально занимающий самое малое место. Наиболее экстремальный вариант — это, конечно, пробел, но тогда все цифры просто «зависают в воздухе», и читабельность резко снижается.
В общем, хочу видеть все табулатуры вот так:
E|·································|
B|·································|
G|·································|
D|·······3·5·4·3···················|
A|·5···5·········5·3···3·0·0·3·3·4·|
E|·································|
Табулатуры в записях мы будем оформлять вот таким блоком кода:
```tab
E|---------------------------------|
B|---------------------------------|
G|---------------------------------|
D|-------3-5-4-3-------------------|
A|-5---5---------5-3---3-0-0-3-3-4-|
E|---------------------------------|
```
Плагин должен отрабатывать только для блоков с припиской tab
или tabs
.
Кстати, плюсану в карму тому, кто угадает, что это за песня :)
Реализация
Задача сводится к тому, что страница режиме просмотра должна подменять содержимое блоков кода с пометкой tab
или tabs
. В терминах Obsidian это называется markdown post-processing — когда ваш markdown уже отрендерен в HTML, и вы можете вклиниться в этот HTML перед самой отрисовкой страницы и подменить там элементы, как вам хочется.
У класса Plugin
есть удобный метод registerMarkdownPostProcessor()
, предназначенный как раз для этой операции, поэтому реализация плагина по сути тривиальна:
class GuitarTabsViewerPlugin extends obsidian.Plugin {
async onload() {
this.registerMarkdownPostProcessor((element, context) => {
const codeblocks = element.findAll('code')
for (let codeblock of codeblocks) {
const blockName = removePrefix(codeblock.className, 'language-')
if (blockName != "tab" && blockName != "tabs") {
continue
}
const targetSymbol = '·'
codeblock.innerHTML = codeblock.innerHTML
.replaceAll('-', targetSymbol) // minus sign
.replaceAll('–', targetSymbol) // en-dash
.replaceAll('—', targetSymbol) // em-dash
.replaceAll('─', targetSymbol) // horizontal line
.replaceAll('‒', targetSymbol) // figure dash
}
})
}
}
Что здесь происходит: element
— это корневой html-элемент контента, что-то вроде . Наша задача — отыскать все теги
. Блоки кода помечаются Обсидианом как класс
language-X
, где X
— имя вашего языка программирования, ну или в нашем случае это та самая пометка tab
/tabs
.
Когда блоки кода найдены, мы подменяем их innerHTML
простой заменой всех известных мне видов горизонтальных черточек на символ ·
.
Я вас поздравляю, наш первый плагин, делающий что-то осмысленное готов:
Вверху — табулатура в режиме редактирования, внизу — в режиме чтения.
Кому-то такой вид табулатуры может показаться сомнительным, но на то они и личные плагины, чтобы делаться под личные запросы. К тому же, в нашем случае главным является образовательный процесс :)
Плагин 2. Suggest TODO
Задача
У меня есть специфическая проблема, связанная со списками дел. Типовая ситуация: у меня есть длинный список TODO. И мне никогда не решиться на выбор очередного пункта из списка на выполнение. В конце концов я заканчиваю либо вообще тотальной прокрастинацией, не приступив ни к одному делу, либо приступаю к наиболее приятным пунктам, игнорируя трудные и неприятные дела, оставляя их висеть в списке месяцами.
Попробуем сломить лень и нерешительность плагином, который будет выбирать TODO из списка за нас.
Как это должно работать: у нас есть заметка с пунктами, например:
или даже так:
Оба вида списков должны поддерживаться. В случае с чек-боксами во внимание нужно принимать только незакрытые пункты. Хочется открыть заметку с TODO-списком и запустить команду Suggest TODO, которая сама предложит нам что-нибудь. Вызов команды хотим иметь в виде двух опций:
Ответ с рекомендованным TODO вывести на экран в отдельном диалоговом окошке.
Реализация
Для начала, чтобы разделить бизнес-логику и служебный код самого плагина, напишем функцию suggestTodoImpl()
, которая принимает на вход сырой markdown
, а возвращает строку со случайно-выбранным TODO. Возвращаемый TODO должен быть очищен от визуальной шелухи и готов к выводу в финальном диалоговом окне. Если алгоритм не может найти ни один доступный к выбору TODO, функция должна вернуть null
:
function suggestTodoImpl(markdown) {
const todos = markdown.split("\n")
// find TODOs
.filter(line => {
if (line.startsWith('- [x]')) return false
return line.startsWith('- ') || line.startsWith('- [ ]')
})
// prettify TODOs
.map(line => removePrefix(removePrefix(line, '- [ ]'), '- ').trim())
if (todos.length === 0) {
return null
}
const randomLine = todos[Math.floor(Math.random() * todos.length)]
return randomLine
}
Имея на руках эту кор-логику можно приступать к самому плагину. Сначала дадим понять плагину, что мы хотим свой Command и иконку на Ribbon:
class TodoSuggestPlugin extends obsidian.Plugin {
async onload() {
this.addCommand({
id: 'Suggest-random-todo',
name: 'Suggest random TODO',
callback: () => {this.suggestTodo()}
})
this.addRibbonIcon('dice', 'Suggest random TODO', (evt) => {
this.suggestTodo()
})
}
}
Оба коллбека апеллируют к this.suggestTodo()
, который нами еще не написан. Но мы это скоро исправим. В this.addCommand()
мы регистрируем команду, которую сможем вызвать через Ctrl + P
, а this.addRibbonIcon()
добавит на левую панель иконку dice
с изображением игральной кости. Оба действия приведут к одной логике:
async suggestTodo() {
const activeView = this.app.workspace.getActiveViewOfType(obsidian.MarkdownView)
if (!activeView) {
new Notice("No active note found!")
return
}
let content
if (activeView.getMode() === "source") {
// Editor mode: Get content from the editor
const editor = activeView.editor
content = editor.getValue()
} else if (activeView.getMode() === "preview") {
// Reading mode: Read content from the file
const file = activeView.file
content = await this.app.vault.read(file)
}
if (!content) {
new Notice("Could not read content!")
return
}
const todo = suggestTodoImpl(content)
if (!todo) {
new Notice("No TODOs available!")
return
}
new ResultModal(this.app, todo).open()
}
Код, пусть и кажется большим, своей большей частью является прелюдией к строчке const todo = suggestTodoImpl(content)
. Задача же найти тот самый content
сводится к тому, чтобы сначала найти активную view в нашем Obsidian, а затем извлечь из нее markdown.
Первое делается через this.app.workspace.getActiveViewOfType(obsidian.MarkdownView)
, второе же зависит от режима в котором сейчас находится view: чтения или редактирования. Поэтому мы и имеем это странное ветвление в попытках заполнить content
.
Так же мы встречаем два новых для нас класса:
Notice
ResultModal
Notice
— это маленькое popup-окошко с оповещением, которое всплывет на пару секунд в верхнем правом углу окна. Например, вот так:
Мы будем его использовать, чтобы оповестить пользователя о нештатной ситуации.
ResultModal
же — наш пользовательский класс, диалоговое окно, в котором мы выведем результат выполнения команды, если все пройдет успешно:
Чтобы создать свое модальное окно, нужно отнаследоваться от Modal
и описать в конструкторе класса наполнение окна:
class ResultModal extends obsidian.Modal {
constructor(app, todo) {
super(app)
this.setTitle('Your TODO')
this.setContent(todo)
new obsidian.Setting(this.contentEl)
.addButton((btn) =>
btn
.setButtonText('OK')
.setCta()
.onClick(() => {
this.close()
})
)
}
}
Кнопка OK просто закрывает модальное окно.
Плагин готов! Вы можете использовать его через Command palette, запустив команду Suggest TODO:
Или просто нажать на иконку на панели слева:
Плагин 3. Top-10 recent edited notes
Задача
У меня до последнего момента в Obsidian был установлен жирный плагин Dataview, на котором нельзя разве что запустить космический корабль. Плагин привносит в ваши заметки sql и JavaScript, чтобы вы могли жонглироватьвашими данными и получать хитрые сводки и выжимки, как вам вашей душе будет угодно.
Мне все это вычислительное изобилие не нужно, и Dataview стоял у меня только с одной целью — иметь под рукой волшебную заметку со списком top-10 последних отредактированных в хранилище файлов. Вы просто создавали заметку вот c таким содержимым:
```dataview
TABLE file.ctime as "Time Modified"
SORT file.ctime DESC
LIMIT 10
```
и в режиме просмотра она показывала вам результат этого «sql»-запроса, с возможностью быстрого перехода по файлам из сводки:
У меня в тестовом хранилище не наберется 10 файлов, но суть вы поняли.
Такой список мне необходим, поскольку, не знаю, закономерно ли это для всех или нет, но
чем меньше времени прошло с последнего изменения заметки, тем выше вероятность, что она мне снова понадобится, чтобы дописать в нее что-то еще.
И да, я знаю о существовании core-плагина Quick Switcher, который можно вызвать по Ctrl + O
. Его проблема в том, что он показывает топ недавно открывавшихся файлов, а это немного другое, и ломает мой флоу.
Итак, наш план:
Сделать top-10, но не в виде заметки, а в виде кастомной ui-страницы на правой панели — где находятся теги, содержание страницы, ссылки и т.п.
Сделать не фиксированный top-10, а скорее top-N, где N будет настраиваться в настройках плагина
Удалить тяжеловесный Dataview за ненадобностью
Реализация
Суть плагина должна сводиться к тому, что было ранее описано в Dataview-запросе:
Достаем список всех файлов хранилища
Сортируем их по дате изменения
Ограничиваем список N файлами
Куда-то отрисовываем результат в виде работающих ссылок на файлы
Так как мы планируем работать с файловым хранилищем в целом, нам нужен класс Vault
, который как раз специализируется на таких задачах. У него есть метод Vault.getMarkdownFiles()
, который выдаст нам список из объектов TFile
. У этого класса в свою очередь можно достать информацию о дате последнего изменения файла: file.stat.mtime
. Опишем свободную функцию для этой логики:
function getTopNFiles(plugin, n) {
const files = plugin.app.vault.getMarkdownFiles().sort(
(f1, f2) => {
return f2.stat.mtime - f1.stat.mtime
}
)
if (files.length > n) {
files.length = n
}
return files
}
Остается понять, где этот код будет вызван. В текущем плагине мы хотим рендерить контент в особую панель справа. Это рендеринг в кастомный View
. В Obsidian Docs есть хороший гайд, который рассказывает, как работать с View: как их объявлять, регистрировать в плагине, отображать и наполнять. Мы пойдем прямо по стопам этого гайда, и получим вот такой код:
class RecentEditedNotesPlugin extends obsidian.Plugin {
async onload() {
this.registerView(
VIEW_TYPE_RECENT_EDITED_NOTES,
(leaf) => new RecentEditedNotesView(leaf, this)
)
this.activateView()
}
async activateView() {
const { workspace } = this.app
let leaf = null
const leaves = workspace.getLeavesOfType(VIEW_TYPE_RECENT_EDITED_NOTES)
if (leaves.length > 0) {
// A leaf with our view already exists, use that
leaf = leaves[0]
} else {
// Our view could not be found in the workspace, create a new leaf
// in the right sidebar for it
leaf = workspace.getRightLeaf(false)
await leaf.setViewState({ type: VIEW_TYPE_RECENT_EDITED_NOTES, active: true })
}
// "Reveal" the leaf in case it is in a collapsed sidebar
workspace.revealLeaf(leaf)
}
}
const VIEW_TYPE_RECENT_EDITED_NOTES = 'recent-edited-notes-view'
class RecentEditedNotesView extends obsidian.ItemView {
plugin = null
constructor(leaf, plugin) {
super(leaf)
this.plugin = plugin
}
getViewType() {
return VIEW_TYPE_RECENT_EDITED_NOTES
}
getDisplayText() {
return 'Recent edited notes'
}
async onOpen() {
}
}
Это все — служебная обвязка, списанная с гайда, чтобы объявить и отобразить на правой панели новый view с айди recent-edited-notes-view
:
View пустой, мы будем его заполнять в методе async onOpen()
, который пока что пуст. Для начала подумаем о высокоуровневом поведении: когда и при каких условиях содержимое view должно обновляться?
На старте Obsidian
При изменении какого-то файла
При переименовывании какого-то файла
Так и запишем:
async onOpen() {
this.plugin.app.vault.on('modify', (file) => {
this.update()
})
this.plugin.app.vault.on('rename', (file) => {
this.update()
})
this.update()
}
Да, у Vault
есть возможность вызвать коллбек на событие изменения или переименовывания файла.
Мы практически на финишной прямой — осталось написать функцию update()
, которая заполнит View нашим топом:
update() {
const container = this.containerEl.children[1]
container.empty()
container.createEl('h4', { text: 'Top-10 recent edited notes' })
const files = getTopNFiles(this.plugin, 10)
const ul = container.createEl('ul')
for (const file of files) {
const li = ul.createEl('li')
const link = li.createEl('a', { text: file.basename })
}
}
Почему в this.containerEl.children[1]
индекс 1, а не ноль — честно, не скажу, так было указано в гайде, и так работает, а экспериментировать я не стал :)
Функция добавляет на view заголовок, достает список файлов через getTopNFiles()
и программно создает html вида:
...
...
...
...
И мы получаем результат:
Опять-таки, у меня не 10 файлов, но вы поняли.
Но у нас не работают ссылки! И как только я не пытался изголяться, чтобы заставить это работать: прописывал href, другие атрибуты; читал официальную документацию; дебажил ссылки в Obsidian и пытался добавлять в теги подсмотренные у Obsidian классы, такие как
.internal-link
— все было без толку. В итоге нашел ответ в Discord-канале — правильное работающее решение выглядит так:
link.addEventListener("click", (event) => {
event.preventDefault() // Prevent default link behavior
app.workspace.openLinkText(file.path, "", false) // Open the note
})
Жалко, что официальная документация не уделила внимания такому важному моменту, как открытие внутренней заметки по ссылке. Но мы с этим, в конце концов, справились.
Не забываем, что я еще обещал сделать в этом плагине настройки. В официальной документации для них есть выделенный гайд. Так же пойдем по нему. У нас будет одна скромная настройка — количество файлов в топе:
class RecentEditedNotesSettingTab extends obsidian.PluginSettingTab {
plugin
constructor(app, plugin) {
super(app, plugin)
this.plugin = plugin
}
display() {
let { containerEl } = this
containerEl.empty()
new obsidian.Setting(containerEl)
.setName('List length')
.setDesc('How long is your list of recently edited notes')
.addText((text) =>
text
.setValue(this.plugin.settings.listLength)
.onChange(async (value) => {
this.plugin.settings.listLength = value
await this.plugin.saveSettings()
})
)
}
}
const DEFAULT_SETTINGS = {
listLength: 10,
}
class RecentEditedNotesPlugin extends obsidian.Plugin {
settings = null
async onload() {
await this.loadSettings()
this.addSettingTab(new RecentEditedNotesSettingTab(this.app, this))
...
}
async loadSettings() {
this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData())
}
async saveSettings() {
await this.saveData(this.settings)
}
}
Мы объявили вкладку с настройками RecentEditedNotesSettingTab
, заполнили ее полем List length и зарегистрировали в плагине. Сохранение и загрузка так же учтены. Теперь мы в состоянии использовать эту настройку по назначению в нашем View. Мы изменим два места.
Первое:
-container.createEl('h4', { text: 'Top-10 recent edited notes' })
+container.createEl('h4', { text: `Top-${this.plugin.settings.listLength} recent edited notes` })
Второе:
-const files = getTopNFiles(this.plugin, 10)
+const files = getTopNFiles(this.plugin, this.plugin.settings.listLength)
Теперь, если пойти в настройки, мы найдем наш плагин в графе Community plugins:
Это единственный плагин с настройками в моем тестовом хранилище, поэтому он здесь присутствует в гордом одиночестве.
Теперь пойдем в мой личный Vault и протестируем, как работает настройка, если изменить значение на 20:
Готово!
Даже в процессе написания этой статьи я активно пользовался этим плагином, чтобы оперативно возвращаться к работе.
Промежуточные итоги
Итак, мы написали три простых плагина. Я надеюсь, что я смог показать главный момент в плагинописании под Obsidian — вы можете просто сесть, создать пару файлов и писать плагин. И ничего не устанавливать.
А что с мобильным Obsidian?
Если у вас есть Obsidian Sync, вы получите свои плагины сразу же после синхронизации устройств. Главное не забудьте про "isDesktopOnly": false
в manifest.json
! Иначе плагины будут показываться на мобильном устройстве, но будут отказываться включаться.
Если вы синхронизируетесь каким-то другим способом, просто добейтесь того, чтобы в папке .obsidian/plugins
оказались папки ваших плагинов, и все так же станет работать.
А где четвертый плагин?
Его мы напишем с вами во второй статье. Он будет чуть крупнее и серьезнее и затронет такую новую тему, как продвинутая верстка внутри заметки и применение css-стилей к этой верстке.
Где взять исходный код
После выпуска второй статьи я размещу ссылку на GitHub со всеми плагинами.
Как опубликовать плагин для сообщества Obsidian?
Этот момент выходит за рамки данной статьи, мы не будем с вами публиковать написанные здесь плагины. Это не ультимативное заявление., но я считаю, что эти плагины слишком простые, местечковые и я не стал бы зашумлять ими раздел Community plugins.
Что касается процедуры публикации плагинов, в документации есть целый раздел, посвященный этой теме. Начать можно отсюда. Если вкратце, вам понадобится репозиторй на GitHub и оформленный по всем правилам и требованиям плагин, который сначала должен будет пройти ревью командой Obsidian. При успешном исходе после итеративного процесса правок по ревью, ваш плагин будет опубликован.