#!/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);\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.") # One line is rewritten on the way in. `web/host.ts` is edited where it lives, so it imports # `./vendor/lib/tokens` — which is correct there and wrong once placed a directory deeper. # Deterministic, like vendor.sh own import rewrite, and it keeps the canonical copy typecheckable # instead of permanently carrying an unresolvable import for the sake of its destination. placed = source.read_text(encoding="utf-8").replace('"./vendor/lib/tokens"', '"./tokens"') if '"./tokens"' not in placed: fail("the tokens import to rewrite", "web/host.ts") # Marked like the rest of the vendored tree, because this step runs AFTER vendor.sh has marked it # and writes the file whole — so without this line the mark is lost on every re-vendor and this one # file starts failing the typecheck again. The canonical copy at web/host.ts stays checked; only # the placed copy is exempt, for the same reason its neighbours are. if not placed.startswith("// @ts-nocheck"): placed = "// @ts-nocheck — vendored, see PROVENANCE.md\n" + placed (VENDOR / "lib" / "host.ts").write_text(placed, 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())