Made with

Acard 플러그인 만들기

Edited October 24, 2026Share link · Read-only

Request edit accessWhen the owner approves, you get an invite link and code by email.

이 글에서 얻는 것

5분 안에 첫 블록 플러그인을 만들어 내 Acard 에서 돌려 보고, 명령·사이드 패널·CSS 플러그인과 마켓에 내는 길, 승인 기준까지 알아요.

한눈에 보기

첫 플러그인까지
5분
템플릿 복사 → validate → zip 설치
  1. 템플릿 복사 (진행 중)블록 · 명령 · CSS 템플릿 중 하나
  2. 고치기 (예정)manifest.json · src/main.js
  3. 검사 (예정)acard-plugin validate
  4. 미리보기 (예정)acard-plugin dev
  5. 설치·제출 (예정)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
cssAcard 화면의 색·글꼴·간격을 바꿔요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.valuesonAction 때 컨트롤 값 {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 KBstorage
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 주소만
승인 후 코드저장소에서 받음승인 순간 사본(해시)만 실행
회수목록에서 내림목록에서 내리고 설치본도 멈춤

마켓에 내기

  1. GitHub 공개 저장소 (진행 중)manifest.json · main.js · styles.css 를 릴리스 파일로
  2. 릴리스 주소 제출 (예정)github.com/<계정>/<저장소>/releases/tag/<버전>
  3. 자동 검사 (예정)오류·경고·통과
  4. 관리자 검토 (예정)승인 또는 사유와 함께 반려
  5. 마켓 공개 (예정)둘러보기에 나와요
  • 처음 낸 사람과 처음 낸 저장소가 그 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 으로도 다시 설치할 수 없어요.