Acard 플러그인 만들기
Request edit accessWhen the owner approves, you get an invite link and code by email.
5분 안에 첫 블록 플러그인을 만들어 내 Acard 에서 돌려 보고, 명령·사이드 패널·CSS 플러그인과 마켓에 내는 길, 승인 기준까지 알아요.
한눈에 보기
- 템플릿 복사 (진행 중)블록 · 명령 · CSS 템플릿 중 하나
- 고치기 (예정)manifest.json · src/main.js
- 검사 (예정)acard-plugin validate
- 미리보기 (예정)acard-plugin dev
- 설치·제출 (예정)zip 설치 또는 GitHub 릴리스 제출
5분 시작
SDK(acard-plugin-sdk)와 템플릿 세 개(블록 · 명령 · CSS)는 Acard 저장소의 packages/ 에 있어요. 공개 템플릿 저장소는 준비 중이에요. 아래는 packages/ 가 보이는 폴더에서 시작해요. CLI 는 아직 npm 에 올리지 않아서 SDK 파일을 bun 으로 바로 실행해요(Bun 1.1 이상).
cp -r packages/acard-plugin-template-block my-plugin
alias acard-plugin="bun $PWD/packages/acard-plugin-sdk/bin/acard-plugin.js"
cd my-plugin
bun run build # src/main.js -> main.js (의존성 없음)
acard-plugin validate # 마지막 줄 ACARD_PLUGIN_OK <id> <version>
acard-plugin dev # http://127.0.0.1:4870/ 미리보기, 저장하면 다시 그려요 (--port N)
acard-plugin pack # <id>-<version>.zip (manifest.json · main.js · styles.css 만)
acard-plugin dev 는 Acard 와 같은 구조(다른 origin 의 sandbox="allow-scripts" 프레임, 같은 CSP)로 블록을 미리 보여 줘요. 명령과 사이드 패널은 zip 으로 내 Acard 에 설치해서 확인해요.
만든 zip 은 설정 > 플러그인 의 zip 칸에 끌어 놓으면 권한 동의 화면이 나오고, 허락하면 설치돼요. 직접 설치한 플러그인에는 "검토되지 않음" 표시가 붙어요.
manifest.json
템플릿의 manifest 를 그대로 보여 드려요. id·name·author·description 만 바꾸면 돼요.
{
"id": "calc-tip",
"name": "Tip Calculator",
"version": "1.0.0",
"minAppVersion": "1",
"description": "Splits a bill with a tip: move the sliders and the result updates.",
"author": "Your Name",
"license": "MIT",
"permissions": [
"storage"
],
"contributes": {
"blocks": [
{
"id": "tip",
"name": "팁 계산기",
"icon": "%",
"description": "금액·팁·인원을 바꾸면 1인당 금액을 바로 계산해요."
}
]
},
"settings": [
{
"key": "currency",
"type": "select",
"label": "통화",
"default": "KRW",
"options": [
{
"value": "KRW",
"label": "원 (KRW)"
},
{
"value": "USD",
"label": "달러 (USD)"
},
{
"value": "EUR",
"label": "유로 (EUR)"
},
{
"value": "JPY",
"label": "엔 (JPY)"
}
]
}
]
}
- id: 영문 소문자로 시작, 2-40자,
acard를 넣거나-plugin으로 끝나면 안 돼요. - version:
x.y.z. GitHub 로 낼 때는 릴리스 태그가 이 값(또는v를 붙인 값)과 같아야 해요. - contributes:
blocks(10개까지) ·commands(20개까지) ·panel({name, icon}하나) ·css: true중 하나 이상. 블록·명령·패널이 있으면 main.js 가 있어야 해요. - settings(30개까지):
toggle·text·textarea·number·slider·select·color. 사용자가 설정 화면에서 바꾼 값이ctx.settings로 와요. - 크기: manifest 16 KB, main.js 256 KB, styles.css 64 KB, zip 1 MB(압축을 푼 합계 2 MB). zip 에는 이 세 파일만, 맨 위나 폴더 하나 안에 넣어요.
권한
| 권한 | 설치 화면 문구 | 쓸 수 있는 것 |
|---|---|---|
| (블록·명령·패널) | 페이지에 블록을 더해요 / 명령을 더해요 / 사이드 패널을 더해요 | render, onAction, run |
| page:read | 지금 연 페이지의 제목과 내용을 읽어요 | ctx.page.read() |
| page:write | 지금 연 페이지에 블록을 넣어요 (누를 때만) | ctx.insert(md) - 실제 클릭 뒤 3초 안, 클릭당 1번 |
| storage | 이 플러그인의 설정과 데이터를 저장해요 (64KB까지) | ctx.storage.get() / set(v) |
| network | 다음 주소와 통신해요 | manifest.network 의 https 주소로만 fetch |
| css | Acard 화면의 색·글꼴·간격을 바꿔요 | styles.css (토큰 변수와 .pl-<id> 규칙만) |
어떤 권한으로도 로그인 정보, 쿠키, 다른 페이지, 다른 작업 공간, Acard 화면 자체에는 닿지 못해요.
UI 컴포넌트와 다시 계산하기
플러그인은 HTML 을 보내지 않고 정해진 노드로 된 트리를 돌려줘요. 그래서 모든 플러그인이 Acard 와 같은 모양(글꼴·색·간격·다크 모드)으로 보여요. 컨트롤(슬라이더·입력·선택·토글)이 바뀌면 onAction(ctx, { name: "change", values }) 다음 render(ctx) 가 다시 불려서 결과가 바로 바뀌어요.
text · heading · stat · stats · compare · table · chart · steps · callout · badge · list · divider · tabs
row · column · grid(2-4열)
button · input · slider · select · toggle · form
값이 바뀌면 바로 다시 계산돼요(슬라이더·입력은 50ms 뒤).
데이터 모양을 보고 알맞은 것을 골라요: 숫자 하나 → stat, {이름: 숫자} → stats, 객체 목록 → compare, 문자열 목록 → steps, 숫자 목록 → 선 차트, 나머지 → text.
템플릿의 팁 계산기 전체 코드예요. 슬라이더를 움직이면 1인당 금액·비교표·차트가 함께 바뀌어요.
// Tip Calculator - an Acard block plugin template (#144).
// Runs inside the Acard sandbox Worker, where the global `acard` exists (types: acard-plugin-sdk/src/types.d.ts).
// render(ctx) returns a UI tree; moving a slider sends an action ("change") with every control value, onAction runs,
// then render runs again with the same ctx - so the result recomputes on every change (Intelligent UI).
/// <reference path="../../acard-plugin-sdk/src/types.d.ts" />
const { ui } = acard;
const DEFAULTS = { amount: 60000, percent: 15, people: 3 };
const CURRENCY = { KRW: { symbol: "₩", step: 1000, max: 500000, digits: 0 }, USD: { symbol: "$", step: 1, max: 1000, digits: 2 }, EUR: { symbol: "€", step: 1, max: 1000, digits: 2 }, JPY: { symbol: "¥", step: 100, max: 50000, digits: 0 } };
function money(value, cur) {
return cur.symbol + value.toLocaleString("en-US", { minimumFractionDigits: cur.digits, maximumFractionDigits: cur.digits });
}
// The values on screen: set by onAction, else what this block saved last time (ctx.state), else the defaults.
let current = null;
function clamp(src, cur) {
const pick = (key, min, max) => {
const n = Number(src[key]);
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : DEFAULTS[key];
};
return { amount: pick("amount", 0, cur.max), percent: pick("percent", 0, 30), people: Math.round(pick("people", 1, 20)) };
}
acard.block("tip", {
async onAction(ctx, action) {
const cur = CURRENCY[ctx.settings.currency] || CURRENCY.KRW;
current = action.name === "reset" ? { ...DEFAULTS } : clamp(action.values, cur);
await ctx.setState(current); // the block remembers the last values in the page
},
render(ctx) {
const en = ctx.lang === "en";
const cur = CURRENCY[ctx.settings.currency] || CURRENCY.KRW;
const v = clamp(current || ctx.state || DEFAULTS, cur);
const tip = v.amount * v.percent / 100;
const total = v.amount + tip;
const each = total / v.people;
const options = [10, 15, 20].map((p) => {
const t = v.amount * (1 + p / 100);
return [p + "%" + (p === v.percent ? " ✓" : ""), money(v.amount * p / 100, cur), money(t, cur), money(t / v.people, cur)];
});
return [
ui.heading(en ? "Tip calculator" : "팁 계산기"),
ui.slider("amount", en ? "Bill" : "금액", v.amount, 0, cur.max, cur.step),
ui.grid(2,
ui.slider("percent", en ? "Tip %" : "팁 %", v.percent, 0, 30, 1),
ui.slider("people", en ? "People" : "인원", v.people, 1, 20, 1)),
ui.stats([
{ label: en ? "Per person" : "1인당", value: money(each, cur) },
{ label: en ? "Tip" : "팁", value: money(tip, cur), delta: "+" + v.percent + "%" },
{ label: en ? "Total" : "합계", value: money(total, cur) },
]),
ui.compare([en ? "Tip" : "팁", en ? "Tip amount" : "팁 금액", en ? "Total" : "합계", en ? "Per person" : "1인당"], options),
ui.chart("bar", Array.from({ length: Math.min(v.people, 10) }, (_, i) => String(i + 1)),
[{ name: en ? "Share" : "몫", data: Array.from({ length: Math.min(v.people, 10) }, () => Math.round(each * 100) / 100) }], cur.symbol),
ui.row(ui.button(en ? "Reset" : "처음 값으로", "reset")),
];
},
});
명령 플러그인
명령은 편집기에서 / 를 누르면 나오는 플러그인 묶음과 빈 줄에서 Tab 으로 여는 기능 찾기에 나와요. 사용자가 고르면 숨은 칸에서 run(ctx) 이 한 번 돌고, 고른 블록 뒤에 ctx.insert(markdown) 로 한 번 쓸 수 있어요(page:write 권한, 64 KB 까지). 전체 실행은 5초 안에 끝나야 해요. 명령은 블록이 없어서 ctx.setState 는 쓸 수 없어요.
{
"id": "insert-date",
"name": "Insert Date",
"version": "1.0.0",
"minAppVersion": "1",
"description": "Adds today's date after the block you picked the command on.",
"author": "Your Name",
"license": "MIT",
"permissions": ["page:write"],
"contributes": { "commands": [{ "id": "today", "name": "오늘 날짜 넣기", "description": "2026년 10월 24일 (토)" }] },
"settings": [{ "key": "format", "type": "select", "label": "날짜 모양", "default": "long", "options": [{ "value": "long", "label": "2026년 10월 24일 (토)" }, { "value": "iso", "label": "2026-10-24" }] }]
}
// main.js - 빌드 없이 그대로 써요.
const DAYS = ["일", "월", "화", "수", "목", "금", "토"];
function dateText(format) {
const d = new Date();
const iso = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
return format === "iso" ? iso : d.getFullYear() + "년 " + (d.getMonth() + 1) + "월 " + d.getDate() + "일 (" + DAYS[d.getDay()] + ")";
}
acard.command("today", {
async run(ctx) {
await ctx.insert(dateText(ctx.settings.format));
},
});
전체 템플릿(회의 메모 머리말 명령 포함)은 packages/acard-plugin-template-command 에 있어요.
사이드 패널 플러그인
contributes.panel 이 있으면 편집기 오른쪽 위 더보기(···) 메뉴에 패널 이름이 생기고, 누르면 오른쪽(좁은 화면에서는 아래에서 올라오는 시트)에 패널이 열려요. 패널도 블록처럼 render · onAction 으로 그려요. 닫으면 패널의 Worker 가 함께 끝나고, 플러그인을 끄거나 회수하면 열린 패널도 닫혀요.
{
"id": "word-stats",
"name": "Word Stats",
"version": "1.0.0",
"minAppVersion": "1",
"description": "Counts the words and characters of the open page in a side panel.",
"author": "Your Name",
"license": "MIT",
"permissions": ["page:read"],
"contributes": { "panel": { "name": "단어 통계", "icon": "▤" } }
}
// main.js - 지금 연 페이지를 읽어(page:read) 단어와 글자 수를 보여 줘요. 다시 세기를 누르면 다시 그려요.
acard.panel({
async render(ctx) {
const page = await ctx.page.read();
const text = page.markdown.replace(/```[\s\S]*?```/g, " ");
const words = text.split(/\s+/).filter(Boolean).length;
const chars = text.replace(/\s/g, "").length;
return [
acard.ui.heading(page.title || "제목 없음", 3),
acard.ui.stats([{ label: "단어", value: words.toLocaleString("ko-KR") }, { label: "글자", value: chars.toLocaleString("ko-KR") }]),
acard.ui.button("다시 세기", "refresh"),
];
},
});
CSS 플러그인
코드 없이 styles.css 하나로 Acard 의 색·글꼴·간격 토큰을 바꿔요. :root 와 :root[data-theme=dark] 의 토큰 변수, 그리고 .pl-<id> 로 시작하는 규칙만 쓸 수 있고, url() · @import · 이스케이프는 막혀요. Acard 가 검사하고 다시 써서 넣기 때문에 그대로 붙지 않아요. 템플릿은 packages/acard-plugin-template-css(파스텔 테마)예요.
API 레퍼런스
| 이름 | 뜻 | 필요한 권한 |
|---|---|---|
| acard.block(id, { render, onAction }) | 블록 하나. render 는 2초 안에 UI 트리를 돌려줘요 | - |
| acard.command(id, { run }) | 명령 하나. 전체 5초 | - |
| acard.panel({ render, onAction }) | 사이드 패널 | - |
| ctx.settings | 설정 화면에서 사용자가 정한 값 | - |
| ctx.state / ctx.setState(v) | 이 블록이 페이지에 저장하는 값(JSON, 16 KB). 명령·패널은 못 써요 | - |
| ctx.values | onAction 때 컨트롤 값 {name: value} | - |
| ctx.lang | "ko" 또는 "en" | - |
| ctx.page.read() | {slug, title, markdown} - 지금 연 페이지만 | page:read |
| ctx.insert(md) | 블록 뒤에 마크다운 넣기 - 클릭 뒤 3초 안, 클릭당 1번(명령은 실행당 1번) | page:write |
| ctx.storage.get() / set(v) | 플러그인마다 작업 공간마다 JSON 하나, 64 KB | storage |
| fetch(url) | manifest.network 의 https 주소로만 | network |
| ctx.update(tree) | render 가 끝나기 전에 일부를 먼저 그려요 | - |
UI 트리 한도: 노드 400, 깊이 8, 문자열 2000자, 전체 64 KB. 문자열은 모두 이스케이프되고 **굵게** 와 ` 코드 만 살아요. 모르는 노드는 버려요. 타입은 packages/acard-plugin-sdk/src/types.d.ts` 에 있어요.
샌드박스: 어떻게 안전하게 도나요
- 플러그인 코드는 acard.dev 가 아닌 별도 사이트의
sandbox="allow-scripts"프레임 안, 그 안의 전용 Worker 에서만 돌아요. Acard 의 쿠키·저장소·화면이 없는 곳이에요. - 프레임의 응답 헤더 CSP 가 네트워크를 막아요.
connect-src는 manifest 의 network 주소뿐이고, 없으면'none'이에요. - render 2초·명령 5초를 넘기거나(저장·페이지 읽기를 기다리는 시간은 빼고, 한 요청 최대 15초), 초당 2 MB 넘게 보내면 Worker 를 끝내고 "멈춤" 을 보여요.
- 공유·게시 페이지에서는 플러그인이 돌지 않아요. 편집자가 "공개 화면에 이 모습 쓰기" 로 저장한 정적 모습만 보여요.
- 오프라인이면 플러그인 블록·명령·패널은 실행하지 않아요.
validate 와 서버의 코드 검사(eval, fetch, cookie …)는 실수를 일찍 알려 주는 안내예요. 난독화로 피할 수 있어요. 실제 경계는 별도 사이트·sandbox·CSP·권한 다리·해시 고정·회수예요.
옵시디언 플러그인과 다른 점
| 옵시디언 | Acard추천 | |
|---|---|---|
| 코드가 도는 곳 | 앱 안(전체 권한) | 별도 사이트 프레임 안 Worker |
| 권한 | 없음(모두 가능) | 선언한 것만, 설치 때 동의 |
| 화면 | 자유로운 DOM | 정해진 UI 노드(같은 디자인) |
| 네트워크 | 자유 | manifest 주소만 |
| 승인 후 코드 | 저장소에서 받음 | 승인 순간 사본(해시)만 실행 |
| 회수 | 목록에서 내림 | 목록에서 내리고 설치본도 멈춤 |
마켓에 내기
- GitHub 공개 저장소 (진행 중)manifest.json · main.js · styles.css 를 릴리스 파일로
- 릴리스 주소 제출 (예정)github.com/<계정>/<저장소>/releases/tag/<버전>
- 자동 검사 (예정)오류·경고·통과
- 관리자 검토 (예정)승인 또는 사유와 함께 반려
- 마켓 공개 (예정)둘러보기에 나와요
- 처음 낸 사람과 처음 낸 저장소가 그 id 의 주인이에요. 다른 저장소로 같은 id 를 낼 수 없어요.
- 승인되는 순간 그 릴리스의 파일을 우리 저장소에 해시와 함께 복사해요. 나중에 GitHub 저장소를 바꿔도 사용자는 승인된 사본만 받아요.
- GitHub 제출은 1시간에 5번, 한 번에 하나씩이에요.
승인 기준과 자주 거절되는 이유
권한이 하는 일보다 많아요
쓰지 않는 page:read · network 는 빼 주세요. 권한이 늘어난 업데이트는 사용자가 다시 동의해야 해요.
network 주소가 넓거나 이유가 없어요
정확한 https 호스트만, 5개까지. 설명에 왜 필요한지 적어 주세요.
description 이 모호해요
무엇을 하는지 한 문장, 마침표로 끝내기. 250자까지.
난독화된 코드
검토할 수 없는 코드는 승인하지 않아요. 빌드 결과도 읽을 수 있게 내 주세요.
태그와 version 이 달라요
릴리스 태그는 manifest 의 version 과 같아야 해요(1.2.0 또는 v1.2.0).
회수가 일어나면
사용자는 상세 화면의 신고 로 문제를 알릴 수 있고(사유 6가지, 하루 5건), 관리자는 신고를 보고 기각하거나 회수해요. 관리자가 회수하면 마켓에서 바로 내려가고, 새로 여는 페이지에서는 실행되지 않아요. 이미 열려 있는 탭은 다음 권한 호출 때나 실행 허가(5분)가 끝날 때 멈춰요. 같은 코드는 zip 으로도 다시 설치할 수 없어요.