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.
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
- Matches
Write|Edit|MultiEdit|apply_patchpython3 "${CLAUDE_PLUGIN_ROOT}/hooks/notion-doc-lint.py" || python "${CLAUDE_PLUGIN_ROOT}/hooks/notion-doc-lint.py" || true
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

