Files
m3e-canvas/scripts/apply-host.py
T
pastilhasandClaude Fable 5 e99b4afbf9 the vendoring machinery: one command reproduces the whole state
scripts/vendor.sh clones upstream, rewrites @/ imports to relative
paths, and applies scripts/apply-host.py. web/host.ts is ours and gets
placed into the vendored tree, because upstream declined it.

a script rather than a .patch, and that is not a preference. between
cfde698 and 5c83ac6 — one day — upstream changed an import line,
re-indented the component from 6 spaces to 8, and added an editAccess
guard. a context diff fails on all three, none of which concern us. so
every anchor matches structure with flexible whitespace and re-indents
to whatever it finds, and a missing anchor stops the run loudly.

all three prompt-export paths are wired, which is what upstream said a
complete version needs. the other two clipboard writes are ShareMenu's
share-link and ask-an-AI blurb, and the reason they are excluded is
written down so the count does not read as the same oversight twice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-05 12:04:34 +01:00

241 lines
9.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Wire the host adapter into the vendored editor.
Run by `vendor.sh` immediately after the import rewrite, so `web/vendor/` becomes a faithful copy of
upstream plus exactly this and nothing else.
── Why a script and not a .patch ──
Upstream moves fast and reformats. Between `cfde698` and `5c83ac6`, one day apart, the i18n import
line changed AND the whole component was re-indented from 6 spaces to 8 AND an `editAccess` guard
appeared. A context diff would have failed on all three, none of which have anything to do with us.
So every anchor here matches on STRUCTURE with flexible whitespace, and re-indents its replacement to
whatever it found. When an anchor genuinely disappears the script stops with the file and the thing
it wanted — loudly, because a silent mis-wire is the failure that matters.
What it wires is what upstream said a complete version needs (lnkiai/m3e-canvas#56): the host hooks,
in ALL THREE export paths rather than one.
── Three, and not the other two ──
A scan for `clipboard.writeText` finds FIVE call sites. Two of them are in `ShareMenu.tsx`, which
upstream added after the review, and they are deliberately NOT wired:
copyAsk an "ask an AI" blurb containing a guide URL and the user's one-line idea
copyLink a share link to the design
Neither is the generated prompt, so neither belongs behind `onExport`. This is written down because
the count looks wrong at a glance, and the previous round of this work was rejected for wiring one
path out of three — the next person to run that scan should not have to re-derive which is which.
"""
import re
import sys
from pathlib import Path
VENDOR = Path(__file__).resolve().parent.parent / "web" / "vendor"
def fail(what: str, where: str) -> None:
raise SystemExit(
f"\n Could not find {what} in {where}.\n"
f" Upstream has moved underneath this script. Read the file, re-anchor, and re-run.\n"
f" This failing is the point — it is how a silent mis-wire is avoided.\n"
)
def reindent(block: str, pad: str) -> str:
"""Re-indent a replacement to sit at the indentation actually found in the file."""
lines = block.split("\n")
return "\n".join(pad + l if l.strip() else l for l in lines)
def sub_once(src: str, pattern: str, build, what: str, where: str, flags=0) -> str:
match = re.search(pattern, src, flags)
if not match:
fail(what, where)
pad = re.match(r"[ \t]*", match.group(0)).group(0)
return src[: match.start()] + build(match, pad) + src[match.end() :]
def add_import(src: str, spec: str, where: str) -> str:
lines = src.split("\n")
idx = [i for i, l in enumerate(lines) if l.startswith("import ")]
if not idx:
fail("any import statement", where)
lines.insert(idx[-1] + 1, spec)
return "\n".join(lines)
# ── Canvas.tsx ────────────────────────────────────────────────────────────────────────────────────
def canvas(src: str, name: str) -> str:
src = add_import(src, 'import { useHost } from "./lib/host";', name)
src = sub_once(
src,
r"[ \t]*export default function Page\(\) \{",
lambda m, pad: m.group(0)
+ f"\n{pad} // Optional host hooks; every default is the standalone behaviour. See lib/host.ts."
+ f"\n{pad} const host = useHost();",
"the component entry point",
name,
)
src = sub_once(
src,
r"([ \t]*)const d = localStorage\.getItem\(DOC_KEY\);\n[ \t]*if \(d\) \{",
lambda m, pad: reindent(
'// Branch on whether the host SUPPLIED a loader, not on what it returned: a host\n'
'// returning null means "no saved document", which must start fresh rather than\n'
"// fall back to this browser's standalone copy. Raised on lnkiai/m3e-canvas#56.\n"
"const hosted = host.loadDoc ? host.loadDoc() : null;\n"
"const d = host.loadDoc ? null : localStorage.getItem(DOC_KEY);\n"
"if (hosted) {\n"
" hadDocRef.current = true;\n"
" applyDoc(hosted, false);\n"
"} else if (d) {",
pad,
).lstrip(),
"the document read",
name,
)
src = sub_once(
src,
r"([ \t]*)const u = localStorage\.getItem\(UI_KEY\);\n[ \t]*if \(u\) \{\n[ \t]*const ui = JSON\.parse\(u\);",
lambda m, pad: reindent(
"const stored = host.loadUi ? null : localStorage.getItem(UI_KEY);\n"
"const ui = host.loadUi ? host.loadUi() : stored ? JSON.parse(stored) : null;\n"
"if (ui) {",
pad,
).lstrip(),
"the UI read",
name,
)
src = sub_once(
src,
r"([ \t]*)localStorage\.setItem\(\s*DOC_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);",
lambda m, pad: reindent(
f"const payload = {m.group(2)};\n"
"if (host.saveDoc) host.saveDoc(payload as Partial<Doc>);\n"
"else localStorage.setItem(DOC_KEY, JSON.stringify(payload));",
pad,
).lstrip(),
"the document write",
name,
re.S,
)
src = sub_once(
src,
r"([ \t]*)localStorage\.setItem\(\s*UI_KEY,\s*JSON\.stringify\((\{[^{}]*\})\),?\s*\);",
lambda m, pad: reindent(
"const ui = {%s};\n" % " ".join(m.group(2)[1:-1].split())
+ "if (host.saveUi) host.saveUi(ui);\n"
"else localStorage.setItem(UI_KEY, JSON.stringify(ui));",
pad,
).lstrip(),
"the UI write",
name,
re.S,
)
return sub_once(
src,
r"([ \t]*)await navigator\.clipboard\.writeText\(effectivePrompt\(doc, widths, lang\)\);",
lambda m, pad: reindent(
"const text = effectivePrompt(doc, widths, lang);\n"
"// The hook's own failure is isolated: a broken host must not also break copying.\n"
"let handled = false;\n"
"try { handled = host.onExport?.(text, doc) === true; } catch {}\n"
"if (handled) return;\n"
"await navigator.clipboard.writeText(text);",
pad,
).lstrip(),
"the toolbar export (1 of 3)",
name,
)
# ── the other two export paths ────────────────────────────────────────────────────────────────────
def prompt_panel(src: str, name: str) -> str:
src = add_import(src, 'import { useHost } from "../lib/host";', name)
src = sub_once(
src,
r"[ \t]*const \[copied, setCopied\] = useState\(false\);",
lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0),
"the copied state",
name,
)
return sub_once(
src,
r"([ \t]*)await navigator\.clipboard\.writeText\(text\);",
lambda m, pad: reindent(
"let handled = false;\n"
"try { handled = host.onExport?.(text, null) === true; } catch {}\n"
"if (handled) { setCopied(true); return; }\n"
"await navigator.clipboard.writeText(text);",
pad,
).lstrip(),
"the copy call (2 of 3)",
name,
)
def inspector(src: str, name: str) -> str:
src = add_import(src, 'import { useHost } from "../lib/host";', name)
# The hook belongs to whichever component owns `prompt`; it is taken beside the copied state.
src = sub_once(
src,
r"[ \t]*const \[copied, setCopied\] = useState\(false\);",
lambda m, pad: f"{pad}const host = useHost();\n" + m.group(0),
"the copied state",
name,
)
return sub_once(
src,
r"([ \t]*)await navigator\.clipboard\.writeText\(prompt\);",
lambda m, pad: reindent(
"let handled = false;\n"
"try { handled = host.onExport?.(prompt, null) === true; } catch {}\n"
"if (handled) { setCopied(true); return; }\n"
"await navigator.clipboard.writeText(prompt);",
pad,
).lstrip(),
"the export copy call (3 of 3)",
name,
)
def main() -> int:
if not VENDOR.exists():
raise SystemExit("web/vendor/ is missing — run scripts/vendor.sh first.")
# `lib/host.ts` is OURS, not upstream's — upstream declined it. The canonical copy is
# `web/host.ts`, edited like any other file we own; this places it where the vendored code's
# imports expect it. Without this step `vendor.sh` leaves those imports dangling, because it
# wipes the directory before copying.
source = VENDOR.parent / "host.ts"
if not source.exists():
raise SystemExit("web/host.ts is missing — it is the source for the vendored lib/host.ts.")
(VENDOR / "lib" / "host.ts").write_text(source.read_text(encoding="utf-8"), encoding="utf-8")
print(" placed lib/host.ts (ours)")
for rel, fn in (
("Canvas.tsx", canvas),
("components/PromptPanel.tsx", prompt_panel),
("components/Inspector.tsx", inspector),
):
path = VENDOR / rel
path.write_text(fn(path.read_text(encoding="utf-8"), rel), encoding="utf-8")
print(f" wired {rel}")
print(" all three export paths wired")
return 0
if __name__ == "__main__":
sys.exit(main())