대시보드
Morit Plugin8. 실제 예제

Runtime v3 예제: 날짜별 학교생활

날짜를 고르면 그날의 시간표를 카드로 보여 주는 완성된 Runtime v3 플러그인입니다. 아래 파일을 그대로 만들면 validate, runtime-test, build가 통과합니다. Morit 저장소의 release 회귀 fixture (test/fixtures/plugins/school-life-runtime-v3)를 줄인 것입니다.

text
school-day/
├─ manifest.json
├─ logic/school.morit
├─ src/school.py
└─ fixtures/day.json      # runtime-test 전용, 패키지에는 들어가지 않음

1. manifest.json

  • logic_modules는 Morit Script 모듈과 export의 입출력 schema를 선언합니다.
  • data source는 capability의 원본 응답을 받아(result_mode: "raw") normalizeDay로 변환합니다.
  • depends_on에 따라 날짜를 바꾸면 다시 요청하고, 이전 날짜의 늦은 응답은 버립니다.
  • data_state가 로딩·빈 데이터·오류·내용을 나눠 표시하고, repeat와 component가 카드를 반복합니다.
json
{
  "schema_version": 2,
  "id": "com.example.school_day",
  "name": "School Day",
  "description": "날짜별 시간표와 급식을 보여 주는 Runtime v3 예제",
  "publisher": "example",
  "version": "1.0.0",
  "min_morit_version": "1.9.0",
  "max_morit_version": "1.999.999",
  "permissions": [],
  "data_policy": "purge",
  "capabilities": [
    {
      "id": "com.example.school_day.lookup",
      "kind": "tool",
      "title": "학교생활 조회",
      "description": "날짜의 시간표와 급식을 조회합니다.",
      "permissions": [],
      "input_schema": {
        "type": "object",
        "properties": {"date": {"type": "string"}},
        "required": ["date"]
      },
      "runtime": {"adapter": "sandbox_python", "entrypoint": "src/school.py"}
    }
  ],
  "ui_extensions": [
    {
      "id": "com.example.school_day.screen",
      "point": "screen",
      "title": "학교생활",
      "permissions": [],
      "config": {
        "ui_schema": 3,
        "logic_modules": [
          {
            "id": "school",
            "path": "logic/school.morit",
            "exports": {
              "normalizeDay": {
                "input_schema": {
                  "type": "object",
                  "required": ["days"],
                  "properties": {"days": {"type": "array", "items": {"type": "object"}}}
                },
                "output_schema": {
                  "type": "object",
                  "required": ["days", "lessonCount"],
                  "properties": {
                    "days": {"type": "array", "items": {"type": "object"}},
                    "lessonCount": {"type": "integer"}
                  }
                }
              }
            }
          }
        ],
        "initial_state": {"selected_date": "{{= runtime.today }}"},
        "computed_state": {"title": "{{= state.selected_date + \" 학교생활\" }}"},
        "data_sources": [
          {
            "id": "school_day",
            "capability": "com.example.school_day.lookup",
            "trigger": "load",
            "depends_on": ["selected_date"],
            "arguments": {"date": "{{= state.selected_date }}"},
            "result_mode": "raw",
            "transform": {"module": "school", "export": "normalizeDay"},
            "cache": {"policy": "stale_while_revalidate", "ttl_seconds": 300},
            "retry": {"max_attempts": 2, "delay_ms": 500, "on": ["timeout", "network", "server"]}
          }
        ],
        "components": {
          "lesson_card": {
            "props_schema": {
              "type": "object",
              "properties": {"lesson": {"type": "object"}},
              "required": ["lesson"]
            },
            "view": {
              "type": "card",
              "children": [
                {"type": "text", "props": {"text": "{{= props.lesson.label }}", "style": "label"}},
                {"type": "text", "props": {"text": "{{= props.lesson.subject }}"}}
              ]
            }
          }
        },
        "view": {
          "type": "column",
          "children": [
            {"type": "text", "props": {"text": "{{= computed.title }}", "style": "title"}},
            {
              "type": "date_field",
              "props": {"state_key": "selected_date", "label": "날짜", "required": true}
            },
            {
              "type": "data_state",
              "props": {
                "source": "school_day",
                "empty_when": "{{= data.school_day != null && data.school_day.lessonCount == 0 }}"
              },
              "slots": {
                "loading": {"type": "skeleton", "props": {"height": 60, "variant": "card"}},
                "empty": {"type": "empty", "props": {"title": "이 날은 수업이 없어요"}},
                "content": {
                  "type": "repeat",
                  "props": {
                    "items": "{{= data.school_day.days[0].lessons }}",
                    "as": "lesson",
                    "key": "{{= lesson.period }}",
                    "layout": "grid",
                    "columns": {"compact": 1, "medium": 2, "expanded": 3}
                  },
                  "slots": {
                    "item": {"type": "component", "props": {"component": "lesson_card", "props": {"lesson": "{{= lesson }}"}}}
                  }
                }
              }
            }
          ]
        }
      }
    }
  ]
}

2. logic/school.morit

HTML이 섞인 급식 메뉴를 텍스트로 바꾸고 교시 라벨을 붙입니다. 문법과 내장 함수는 Morit Script 레퍼런스를 참고하세요.

text
function cleanMenu(items) {
  return items.map(item => html.toText(item)).filter(item => item != "");
}

export function normalizeDay(input) {
  const days = input.days.map(day => ({
    date: day.date,
    lessons: day.lessons.map(lesson => ({
      period: lesson.period,
      subject: lesson.subject,
      label: string.from(lesson.period) + "교시"
    })),
    meals: day.meals.map(meal => ({
      name: meal.name,
      menu: cleanMenu(meal.menu)
    }))
  }));
  return {days: days, lessonCount: days.reduce((count, day) => count + day.lessons.length, 0)};
}

3. src/school.py

sandbox_python capability는 handle(request) 함수 하나를 구현합니다. request에는 query와 arguments가 들어 있고, 결과는 summary·data·evidence 객체입니다. 외부 API를 쓰려면 이 capability를 http_json adapter로 바꿔도 화면 설정은 그대로입니다.

python
# A real plugin would call a school API here; this example returns fixed data.
DAYS = [
    {
        "date": "2026-09-16",
        "lessons": [
            {"period": 1, "subject": "국어"},
            {"period": 2, "subject": "수학"},
        ],
        "meals": [{"name": "점심", "menu": ["<b>현미밥</b>", "된장국<br>두부"]}],
    }
]


def handle(request):
    """The Host calls handle() with {"query": ..., "arguments": {...}}."""
    date = request.get("arguments", {}).get("date")
    days = [day for day in DAYS if day["date"] == date]
    return {"summary": f"{date} 학교생활", "data": {"days": days}, "evidence": []}

4. fixtures/day.json

runtime-test가 capability 대신 사용하는 응답입니다.

json
{
  "days": [
    {
      "date": "2026-09-16",
      "lessons": [
        {
          "period": 1,
          "subject": "국어"
        },
        {
          "period": 2,
          "subject": "수학"
        },
        {
          "period": 3,
          "subject": "과학"
        }
      ],
      "meals": [
        {
          "name": "점심",
          "menu": [
            "<b>현미밥</b>",
            "된장국<br>두부",
            "사과 &amp; 배",
            ""
          ]
        }
      ]
    },
    {
      "date": "2026-09-17",
      "lessons": [],
      "meals": []
    }
  ]
}

5. 검증과 build

bash
npx -y @morit/cli plugin validate .
npx -y @morit/cli plugin runtime-test . --fixture fixtures/day.json \
  --now 2026-09-16T00:30:00Z --timezone Asia/Seoul --json
npx -y @morit/cli plugin preview . --output dist/preview.html
npx -y @morit/cli plugin build . --output dist/school-day.mplg

runtime-test 결과의 output에는 변환된 days와 lessonCount가, inspector.computed_state에는 {"title": "2026-09-16 학교생활"}이 들어 있어야 합니다. 마지막으로 앱에 설치해 날짜 선택, 빈 날짜, 오류 후 다시 시도를 확인합니다.