Skip to content
Writing
Hook

Hooks

What notion-doc runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.

From plugin
notion-doc
1071 skill1 hook
Install
> /plugin marketplace add heyman333/agent-notion-template-docs
> /plugin install notion-doc@agent-notion-template-docs

Ships with notion-doc. Installing the plugin gets these hooks.

What fires, and when

PostToolUse

  • MatchesWrite|Edit|MultiEdit|apply_patchpython3 "${CLAUDE_PLUGIN_ROOT}/hooks/notion-doc-lint.py" || python "${CLAUDE_PLUGIN_ROOT}/hooks/notion-doc-lint.py" || true
Read hooks/hooks.json

In the plugin's words

How notion-doc describes its own hook set.

notion-doc: checks that written HTML documents follow the style canon.

Where it lives

  • hooks/notion-doc-lint.pyRunsGitHub
    Read the script
    #!/usr/bin/env python3
    """PostToolUse hook — after an HTML document is written, check the notion-doc canon.
    
    Two modes of operation:
    
    1. If the file carries the canon fingerprint (--callout-blue, --radius, .page)
       → run lint.py and pass its errors through. Catches the agent writing new
       CSS or inventing classes.
    2. If the fingerprint is missing but the file is a standalone document-shaped
       HTML → the skill was skipped, so emit a one-line reminder.
    
    Apps, components, and build artifacts are left alone. On any exception it
    prints "{}" and exits 0 (fail-open) — the hook must never block work.
    
    Contract: stdin JSON → stdout hookSpecificOutput.additionalContext.
    Runs under both hook runtimes, which disagree on how a write is reported:
    
      Claude Code  tool_input.file_path  ("Write" / "Edit" / "MultiEdit")
      Codex        tool_input.command    ("apply_patch" — paths live in the patch
                                          body, relative to the payload's cwd)
    
    So paths are collected from both shapes rather than from one fixed key, and a
    single call may carry several files.
    """
    import importlib.util
    import json
    import pathlib
    import re
    import sys
    
    ROOT = pathlib.Path(__file__).resolve().parent.parent
    LINT = ROOT / "skills" / "notion-doc" / "lint.py"
    SKILL = ROOT / "skills" / "notion-doc" / "SKILL.md"
    TEMPLATE = ROOT / "skills" / "notion-doc" / "template.html"
    
    FINGERPRINT = ("--callout-blue", "--radius", ".page")
    
    # Don't even look at apps or build artifacts — documents only.
    SKIP_PARTS = {"node_modules", "dist", "build", ".next", "out", "vendor",
                  "coverage", ".venv", "target", "public"}
    APP_SIGNALS = (
        'id="root"', "id='root'", 'id="app"', '<script type="module"',
        "{{", "<%", "data-reactroot", "__NEXT_DATA__",
        "</template>", "<slot", "{% ",
    )
    # Framework attribute prefixes only count after whitespace inside a tag.
    # As plain substrings, "padding-left" matches ng- and "max-width:" matches th:.
    APP_ATTR_RE = re.compile(
        r"\s(?:ng-[a-z]|th:[a-z]|v-(?:if|for|else|bind|model|on)\b|x-data\b|asp-[a-z])")
    
    # apply_patch bodies name their files in headers like "*** Add File: doc.html".
    PATCH_FILE_RE = re.compile(r"^\*\*\* (?:Add|Update|Move to) File: (.+)$", re.M)
    PATH_KEYS = ("file_path", "filePath", "path")
    
    
    def load_linter():
        spec = importlib.util.spec_from_file_location("notion_doc_lint", LINT)
        mod = importlib.util.module_from_spec(spec)
        spec.loader.exec_module(mod)
        return mod
    
    
    def looks_like_document(text):
        """Standalone document-shaped HTML — app shells and template fragments excluded."""
        low = text.lower()
        if "<html" not in low and "<!doctype" not in low:
            return False
        if not re.search(r"<h1[ >]", low):
            return False
        if any(sig.lower() in low for sig in APP_SIGNALS):
            return False
        return not APP_ATTR_RE.search(low)
    
    
    def build_message(path, findings):
        lines = [f"notion-doc check failed — {path}", ""]
        lines += [f"  {'ERROR' if s == 'error' else 'WARN '} [{c}] {m}"
                  for s, c, m in findings]
        lines += [
            "",
            f"Do not change a single character of the <style> block in {TEMPLATE} — fix only the body.",
            f"Block selection rules are in {SKILL}.",
            f"Re-check with: python3 {LINT} {path}",
        ]
        return "\n".join(lines)
    
    
    def written_paths(data):
        """Every file the call touched, whichever shape the runtime reported it in."""
        tool_input = data.get("tool_input")
        if isinstance(tool_input, str):          # some runtimes pass the raw command
            tool_input = {"command": tool_input}
        if not isinstance(tool_input, dict):
            return []
    
        raw = [tool_input[k] for k in PATH_KEYS if isinstance(tool_input.get(k), str)]
        for value in tool_input.values():        # apply_patch / heredoc patch bodies
            if isinstance(value, str) and "*** " in value:
                raw += PATCH_FILE_RE.findall(value)
    
        cwd = pathlib.Path(data.get("cwd") or ".")
        out = []
        for item in raw:
            path = pathlib.Path(item.strip())
            if not path.is_absolute():
                path = cwd / path
            if path not in out:
                out.append(path)
        return out
    
    
    def check_path(path):
        if path.suffix.lower() not in (".html", ".htm"):
            return None
        if SKIP_PARTS & set(path.parts):
            return None
        if not path.is_file():
            return None
    
        text = path.read_text(encoding="utf-8", errors="replace")
    
        if not all(m in text for m in FINGERPRINT):
            if looks_like_document(text):
                return (f"{path} is a document-shaped HTML file but the notion-doc "
                        f"skill was not applied. Read {SKILL} and rebuild it by "
                        f"copying {TEMPLATE} — do not write your own CSS.")
            return None
    
        linter = load_linter()
        findings = linter.check(path, linter.canon_style())
        if not any(s == "error" for s, _, _ in findings):
            return None
        return build_message(path, findings)
    
    
    def run(data):
        messages = [m for m in (check_path(p) for p in written_paths(data)) if m]
        return "\n\n".join(messages) or None
    
    
    def main():
        # Hook stdout is a pipe, so Python picks the locale encoding (cp949, cp1252 ...)
        # and the non-ASCII in our messages would raise. Claude Code reads it as UTF-8.
        if hasattr(sys.stdout, "reconfigure"):
            sys.stdout.reconfigure(encoding="utf-8", errors="replace")
        output = "{}"
        try:
            message = run(json.load(sys.stdin))
            if message:
                output = json.dumps({"hookSpecificOutput": {
                    "hookEventName": "PostToolUse",
                    "additionalContext": message,
                }}, ensure_ascii=False)
        except Exception:
            output = "{}"
        try:
            print(output)
        except Exception:
            pass
        sys.exit(0)
    
    
    if __name__ == "__main__":
        main()
    
  • hooks/test_gating.pyGitHub
    Read the script
    #!/usr/bin/env python3
    """훅 게이팅 회귀 테스트 — 어디에 반응하고 어디에 침묵하는지 고정한다.
    
    침묵해야 할 자리에서 말하는 훅은 훅을 끄게 만든다. 실제로 한 번 당한 오탐
    ("padding-left" 가 ng- 로, "max-width:" 가 th: 로 잡혀 평범한 문서가
    프레임워크 템플릿으로 분류됐다)을 여기 케이스로 박아 둔다.
    
        python3 hooks/test_gating.py
    """
    import json
    import os
    import pathlib
    import subprocess
    import sys
    import tempfile
    
    HOOK = pathlib.Path(__file__).resolve().parent / "notion-doc-lint.py"
    ROOT = HOOK.parent.parent
    DOC = '<!DOCTYPE html><html><body>{}</body></html>'
    
    # (이름, 파일 내용 또는 기존 경로, 말해야 하는가)
    CASES = [
        ("정본 그대로인 문서",      ROOT / "examples/sample.html",      False),
        ("정본 그대로인 기술 문서", ROOT / "examples/sample-tech.html",  False),
        ("skill 미적용 문서",       ROOT / "examples/before.html",       True),
        ("CSS 드리프트", None, True),
        ("지어낸 클래스", None, True),
        ("앱 껍데기",        DOC.format('<div id="root"></div><h1>App</h1>'), False),
        ("Thymeleaf 템플릿", DOC.format('<h1 th:text="${t}">x</h1>'),         False),
        ("Vue 템플릿",       DOC.format('<h1 v-if="ok">x</h1>'),              False),
        # 오탐 회귀: 평범한 문서에 흔한 CSS 속성이 프레임워크로 오인되면 안 된다
        ("padding-/width: 가 있는 평범한 문서",
         DOC.format('<style>.a{padding-left:1px;max-width:9px}</style><h1>문서</h1>'), True),
    ]
    
    
    def send(payload):
        r = subprocess.run([sys.executable, str(HOOK)], input=json.dumps(payload),
                           capture_output=True, text=True, timeout=30)
        assert r.returncode == 0, f"훅은 항상 0 으로 끝나야 한다: {r.returncode}"
        return json.loads(r.stdout or "{}")
    
    
    def fire(file_path, tool="Write"):
        """Claude Code 모양: tool_input.file_path"""
        return send({"tool_name": tool, "tool_input": {"file_path": str(file_path)}})
    
    
    def fire_patch(*rel_paths, cwd=None):
        """Codex 모양: apply_patch 본문에서 cwd 기준 상대 경로를 읽어낸다."""
        body = "*** Begin Patch\n" + "".join(
            f"*** Update File: {p}\n@@\n+<p>x</p>\n" for p in rel_paths) + "*** End Patch"
        return send({"tool_name": "apply_patch", "cwd": str(cwd or ROOT),
                     "tool_input": {"command": body}})
    
    
    def main():
        sample = (ROOT / "examples/sample.html").read_text()
        drift = sample.replace("  .callout {\n    display: flex;",
                               "  .callout {\n    box-shadow: 0 4px 12px rgba(0,0,0,.2);\n    display: flex;")
        bad_class = sample.replace('<div class="callout callout-blue">', '<div class="hero-card">')
        synth = {"CSS 드리프트": drift, "지어낸 클래스": bad_class}
    
        failed = 0
        with tempfile.TemporaryDirectory() as tmp:
            for name, content, should_speak in CASES:
                if content is None:
                    content = synth[name]
                if isinstance(content, pathlib.Path):
                    target = content
                else:
                    target = pathlib.Path(tmp) / f"{abs(hash(name))}.html"
                    target.write_text(content, encoding="utf-8")
                spoke = bool(fire(target))
                ok = spoke == should_speak
                failed += not ok
                print(f"{'ok  ' if ok else 'FAIL'}  {name}: "
                      f"{'말함' if spoke else '침묵'} (기대 {'말함' if should_speak else '침묵'})")
    
        # Codex 의 apply_patch 는 파일 경로를 패치 본문에만 싣는다. 한 번에 여러 개도 온다.
        for name, rels, should_speak in [
            ("apply_patch · 정본 문서", ["examples/sample.html"], False),
            ("apply_patch · skill 미적용", ["examples/before.html"], True),
            ("apply_patch · 여러 파일", ["examples/sample.html", "examples/before.html"], True),
            ("apply_patch · 비 HTML", ["README.md"], False),
        ]:
            spoke = bool(fire_patch(*rels))
            ok = spoke == should_speak
            failed += not ok
            print(f"{'ok  ' if ok else 'FAIL'}  {name}: "
                  f"{'말함' if spoke else '침묵'} (기대 {'말함' if should_speak else '침묵'})")
    
        # 비 HTML·빌드 경로·깨진 입력은 무조건 침묵
        for name, path in [("비 HTML", ROOT / "README.md"),
                           ("node_modules 경로", ROOT / "node_modules/x.html")]:
            spoke = bool(fire(path))
            failed += spoke
            print(f"{'ok  ' if not spoke else 'FAIL'}  {name}: {'말함' if spoke else '침묵'} (기대 침묵)")
        r = subprocess.run([sys.executable, str(HOOK)], input="깨진 입력",
                           capture_output=True, text=True)
        ok = r.returncode == 0 and r.stdout.strip() == "{}"
        failed += not ok
        print(f"{'ok  ' if ok else 'FAIL'}  깨진 입력: fail-open")
    
        # 레거시 코드페이지 회귀: 윈도우에서 stdout 이 cp949 로 떨어져도 말해야 한다.
        # (예전엔 em dash 가 UnicodeEncodeError 를 내고 fail-open 에 먹혀 완전 침묵했다)
        env = {**os.environ, "PYTHONIOENCODING": "cp949"}
        payload = json.dumps({"tool_name": "Write", "tool_input": {
            "file_path": str(ROOT / "examples/before.html")}})
        r = subprocess.run([sys.executable, str(HOOK)], input=payload.encode(),
                           capture_output=True, env=env, timeout=30)
        ok = r.returncode == 0 and bool(json.loads(r.stdout.decode("utf-8") or "{}"))
        failed += not ok
        print(f"{'ok  ' if ok else 'FAIL'}  cp949 stdout 훅: {'말함' if ok else '침묵'} (기대 말함)")
    
        r = subprocess.run([sys.executable, str(ROOT / "skills/notion-doc/lint.py"),
                            str(ROOT / "examples/before.html")],
                           capture_output=True, env=env, timeout=30)
        ok = r.returncode == 1 and b"\xe2\x9c\x97" in r.stdout  # UTF-8 로 나온 U+2717
        failed += not ok
        print(f"{'ok  ' if ok else 'FAIL'}  cp949 stdout lint.py: "
              f"{'UTF-8 출력' if ok else r.stderr.decode('utf-8', 'replace').strip()[-80:]}")
    
        print(f"\n{'실패 ' + str(failed) + '건' if failed else '전부 통과'}")
        return 1 if failed else 0
    
    
    if __name__ == "__main__":
        sys.exit(main())
    

Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.

Ships withnotion-doc

Make your AI agent write documents like Notion — not like an AI. Ask an AI agent to create a document and you'll often get the same kind of output: a purple gradient hero, emoji-heavy headings, cards with drop shadows.

Get the whole plugin
Stats
107
Stars
13
Forks
Active
Maintenance
Python
Language
MIT
License
2h ago
Last commit
1mo ago
Created
5h ago
Added

Repo: heyman333/agent-notion-template-docs