빠르게 시작하기
새 플러그인을 시작하는 가장 빠른 길은 examples/plugins/의
참조 예제 중 하나를 복사하는 것입니다.
examples/plugins/community-template/— 복사할 것은 이쪽입니다. Baram이 예약한baram-접두사 밖의 id를 쓰는 샌드박스 상태바 플러그인이고, 커뮤니티 레지스트리가 받는 ZIP을 빌드하는 릴리스 워크플로가 들어 있습니다. 바꿀 것은 그 README에 있습니다.examples/plugins/word-count/— Baram이 게시하는 샌드박스 참조입니다.editor:readonly+events+statusbar플러그인이 쓴 선언형 상태바 항목입니다. 메인 렐름에서 아무것도 필요로 하지 않는다는 점이 핵심입니다. id가baram-으로 시작하므로 복사하면 id를 바꾸십시오.examples/plugins/bullet-threading/— trusted 참조입니다.tiptapExtensions로 기여하는 ProseMirror 데코레이션 플러그인과 설정 탭입니다. 플러그인이 에디터 안에서 돌아야 한다면 이쪽을 복사하십시오 — 그것은 메인 렐름을 필요로 하고, 따라서 그런 플러그인은 구조적으로trust: "trusted"입니다. 샌드박스 티어는tiptapExtensions를 거부합니다. 복사본은 개발 빌드에서만 폴더로 로드되고, 커뮤니티 레지스트리는 받지 않습니다 — 샌드박스 플러그인만 받습니다.examples/plugins/ai-summary/— 임의의 DOM을 쓰는 trusted 티어입니다: Shadow DOM 사이드바 패널 + 설정 탭,ai+storage. 임의의 DOM이 정말로 필요할 때만 복사하십시오. 릴리스 워크플로의 배포 allowlist에 없습니다 — 1.x 계열은 v0.5.0에서 레지스트리에서 철회됐고, 선언형sidebar기여는 아직 없습니다.bullet-threading/복사본처럼 이 복사본도 개발 빌드에서만 폴더로 로드되고, 커뮤니티 레지스트리는 받지 않습니다.
폴더 둘은 내부 테스트 픽스처이고 템플릿이 아닙니다. 둘 다 빌드 단계 없이 손으로 쓴 파일
하나이고, plugin-release.yml이 둘 다 배포를 거부합니다.
examples/plugins/sandbox-smoke/는 수동 스모크 실행 중에 샌드박스 티어의 브로커 표면을 찔러 봅니다.examples/plugins/malicious-fixture/는 적입니다. 권한 둘을 갖고 나머지 전부를 요구하며, CI가 모든 호출이 거부되는 것을 단정합니다. 이 티어가 허용하지 않는 것의 목록으로 읽어 두면 좋습니다.
에디터 API(두 티어 공통)
섹션 제목: “에디터 API(두 티어 공통)”ctx.editor는 두 티어에서 같습니다 — 마크다운이고 비동기입니다. getMarkdown() /
setMarkdown()이 앱 자신의 왕복 파이프라인을 거치므로, 읽은 것을 그대로 되쓸 수 있습니다.
getSelection()은 위치와 선택의 평문, 그리고 ref를 주고, insertMarkdown(md, { replace: ref })는
정확히 그 범위를 바꾸며, insertText()는 평문을 입력합니다. 모든 쓰기가 실행 취소 한
단계입니다. 읽기에는 editor 또는 editor:readonly가, 쓰기에는 editor가 필요합니다. 모든
메서드와 거부 코드는
컨텍스트: 명령·에디터·파일·이벤트에
있습니다.
const before = await ctx.editor.getMarkdown();await ctx.editor.setMarkdown(`${before}\n\n---\n`);샌드박스 티어에서는 문서를 읽은 결과가 응답에 실려 오지 않습니다 — 호스트가 세워 두고 샌드박스가
가져갑니다 — 그러나 그것은 보이지 않고, getMarkdown()은 그냥 프로미스입니다.
설계할 때 염두에 둘 것이 셋 있습니다.
setMarkdown()은 거절할 수 있고, 다시 시도해야 합니다. 메인 스레드 밖에서 파싱하는데, 그 사이에 문서가 바뀌면 — 사용자가 한 글자를 입력하거나 다른 마크다운 탭으로 전환하는 것만으로도 — 변경을 덮어쓰지 않고code: "document-changed"로 거절합니다. 아직 진행 중인 탭 전환이나 마크다운 문서가 아닌 탭으로의 전환이면 대신code: "surface-blocked"로 거절합니다. 큰 문서에서 누군가 계속 타이핑하고 있으면 반복해서 실패할 수 있습니다. 의도한 것입니다 — 대안은 사용자가 방금 쓴 것을 조용히 버리는 것이기 때문입니다.- 삽입을 묶으십시오.
insertText()나insertMarkdown()하나가 실행 취소 한 단계이므로, AI 스트림을 토큰마다 삽입하면 사용자에게Cmd+Z천 번을 안기는 셈입니다 — 게다가 샌드박스 티어에서는 삽입 비용을 넣는 내용만이 아니라 문서 전체의 크기를 바탕으로 매기므로, 큰 파일에서 토큰마다 넣으면 예산이 바닥납니다. 버퍼에 모아 덩어리로 삽입하십시오. - 읽은 뒤
ref로 바꾸십시오. 몇 초 걸리는 AI 다시 쓰기는getSelection()에서 받은ref를 쥐고 있다가 넘겨야 합니다.ref없는 쓰기는 그때 선택된 곳에 들어가고, 그곳은 전혀 다른 자리일 수 있습니다. 선택의text는 평문이므로, 굵게나 링크를 지켜야 하는 다시 쓰기는 넘기는 마크다운에 그것을 다시 넣어야 합니다.
샌드박스 티어에서 에디터 호출은 호출 횟수가 아니라 드는 일로 측정됩니다 — 낙서 노트를 읽는
것은 거의 무료이고, 10,000줄 파일을 반복해서 읽는 것은 아닙니다. 범위를 확인한 뒤에 거부된 쓰기도
— 예를 들어 글이 바뀐 ref — 그 범위의 길이만큼, 에디터가 그 자리에서 사용자의 커서 둘레에
마크다운 문법을 펼쳐 보이고 있었다면 쓰기 비용 전부만큼 예산을 쓰므로, 낡은 ref로 거듭 다시
시도하면 예산이 바닥납니다. 대신 선택을 다시 읽으십시오. 호출이 code: "budget"으로
거부되면, 폴링하는 대신 ctx.events에서 받을 수 있는 읽기나 묶을 수 있는 삽입이 있는지
보십시오. trusted 티어에는 이런 예산이 없습니다.
샌드박스 티어의 API는 다릅니다
섹션 제목: “샌드박스 티어의 API는 다릅니다”"trust": "sandboxed" 플러그인은 자기 격리된 웹뷰에서 돌고 더 좁은, 데이터만 다루는 컨텍스트를
받습니다. 쓸 때 중요한 차이가 셋입니다.
-
files경로는 알려 주지 않는 볼트 루트 기준 상대 경로입니다. 그래서readFile("a.md")처럼 쓰고, 볼트 루트 자체를 가리킬 때는listDir("")입니다. 절대 경로나..은 거부됩니다. 파일 이벤트에서 받은{ context }를 넘기면 그 이벤트가 온 볼트를 계속 가리킵니다.ctx.events.on("file:open", async ({ context, path }) => {const text = await ctx.files.readFile(path, { context });}); -
settings는 사용자의 답이고 읽기 전용입니다.contributions.settings에 필드를 선언하면 설정 → 플러그인 아래 그 플러그인 페이지에 렌더됩니다.await ctx.settings.getAll()로 읽으면 선언한 필드마다 선언한 타입의 값 하나를 항상 돌려줍니다. 여기서는 비동기이고, trusted 티어의getAll()은 값을 바로 돌려줍니다.const { prefix } = await ctx.settings.getAll();ctx.events.on("settings:changed", async () => {const next = await ctx.settings.getAll(); // 이벤트는 값을 싣지 않는다});“사용자의 답”이라는 데서 따라오는 것들:
- setter가 없습니다. 사용자가 고른 값이 그 아래에서 움직여선 안 됩니다. 자기 상태에는
ctx.storage를 쓰십시오. settings:changed는 아무것도 싣지 않습니다 — 다시 읽으십시오. (값은 일부러 밀어 보내는 프레임에서 빼 둡니다.) 두 티어 모두events가 아니라settings로 게이트되고, 디바운스되므로 입력 중인 필드는 값이 멎은 뒤에 한 번 알려 줍니다.- 값은 여러분의 현재 매니페스트를 기준으로 해석됩니다. 그래서 업데이트가 필드 타입을 바꾸거나
키를 없애면, 플러그인은 옛 값이 아니라 새 기본값을 봅니다. 키 이름을 바꾸면 초기화됩니다 —
매니페스트가
number라고 한 자리에string을 절대 넘기지 않기 위한 대가입니다. - 필드는 최대 16개, 문자열 값은 512자까지입니다. 매니페스트가
settings권한도 선언해야 필드가 렌더됩니다.
- setter가 없습니다. 사용자가 고른 값이 그 아래에서 움직여선 안 됩니다. 자기 상태에는
-
ui는 DOM이 아니라 데이터입니다.ctx.ui.showNotification(message, type?)(호스트가 자기 배지에 플러그인 이름을 붙여 토스트를 표시하고, 앱에 토스트 자리가 하나뿐이므로 4초에 한 번으로 제한합니다)과, 매니페스트의contributions.statusBar에 선언한 항목에 쓰는ctx.ui.setStatusBarText(id, text)가 있습니다.addStyle도, 패널onMount(el)도 없습니다 — 그것들은"trust": "trusted"가 필요합니다.
선언형 상태바 항목은 플러그인 코드가 돌기 전에 매니페스트에서 등록됩니다 — 그래서 샌드박스가
아직 부팅하는 동안에도 보입니다 — 그리고 command가 있는 항목은 클릭할 수 있습니다. 불러오기가
실패하면 다시 제거됩니다.
플러그인 활성화가 끝나면, 이미 열려 있는 파일이 있다면 호스트가 그 파일에 대한 합성 file:open을
보냅니다. 그래야 시작할 때 불려온 플러그인이 사용자가 탭을 바꿀 때까지 자기가 어디 있는지 모르는
일이 없습니다.
기여 id(commands[].id, statusBar[].id, menu[].id, slash[].id, settings[].key, 그리고
상태바 · 메뉴 · 슬래시 항목이 가리키는 command)는 ^[A-Za-z0-9_-]+$에 맞아야 하고 자기 구역 안에서
유일해야 합니다. 명령은 최대 쉰 개, 상태바 항목은 다섯 개, 메뉴 항목은 다섯 개, 슬래시 항목은 열 개,
설정 필드는 열여섯 개까지 선언할 수 있고, 설정의 default는 그 필드가 선언한 타입이어야 합니다. 호스트가
<pluginId>.<command>와 <pluginId>:sb:<item>으로 이름 공간을 붙이므로, 뒷부분에 .이나 :가
있으면 그 id가 모호해집니다. menu와 slash는 명령을 에디터의 우클릭 메뉴와 슬래시 메뉴에
올립니다 — 플러그인 매니페스트 참조.
플러그인 프로젝트는 이렇게 생겼습니다.
my-plugin/ baram-plugin.json # 매니페스트 (필수) src/index.ts # 소스 (TypeScript 권장) dist/index.mjs # 빌드된 ESM 번들 — "main" 이 가리키는 것 plugin-api.d.ts # examples/plugins/plugin-api.d.ts 에서 복사 types.d.ts # examples/plugins/types.d.ts 에서 복사 package.json tsconfig.json-
examples/plugins/plugin-api.d.ts와examples/plugins/types.d.ts를 소스 옆으로 복사합니다(또는 예제들의tsconfig.json처럼 상대include/path로 직접 참조합니다). -
거기서 타입을 import합니다 — 샌드박스 플러그인은 템플릿처럼
SandboxContext를 씁니다(trusted플러그인은 대신ExtensionContext를 받습니다).import type { SandboxContext } from "../plugin-api"; -
activate(context)를 씁니다(그리고 선택적으로deactivate()). -
esbuild로 ESM 번들 하나를 빌드합니다.
터미널 창 npx esbuild src/index.ts --bundle --format=esm --outfile=dist/index.mjs -
아무것도 패키징하지 않고 플러그인을 개발 모드로 불러옵니다 — 설정 → 플러그인 → 개발자 → 개발 플러그인 폴더 불러오기를 누르고 폴더 선택기로 플러그인 디렉터리를 가리킵니다. 릴리스 빌드에서는 먼저 그 구역에서 개발자 모드를 켜십시오 — 켜기 전에는 버튼이 보이지 않습니다. 플러그인은
sandboxed티어여야 하고, id가baram-으로 시작하면 안 됩니다. 로컬 개발 루프 참조.

