Files
ditto.site/compiler/src/generate/menu.ts
T
Samraaj BathandClaude Fable 5 77be868ee3 Generated-code hygiene: listener cleanup, shared cn(), whitespace collapse, probe stripping, sub-pixel snapping, clone-origin metadata, dependency pinning
- DittoWire/Accordion/DropdownMenu runtime templates: AbortController-based
  listener cleanup, idempotent effects, orphan panel removal on unmount
- Emit a single src/lib/utils.ts cn() instead of a copy per component file
- Collapse captured whitespace runs under white-space:normal (pre* preserved)
- Tag source-injected font-metric probe nodes at capture; drop them from IR
- Snap sub-pixel arbitrary lengths (integer within 0.1px, else 1 decimal)
- Emit SITE_ORIGIN (src/lib/site.ts, env-overridable): sitemap/robots/
  canonical/og:url/JSON-LD resolve against the clone's own origin instead of
  the source domain
- Pin lottie-web in generated package.json whenever DittoLottie is emitted

144 tests pass (21 new), typecheck clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 22:51:50 -07:00

169 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { IR, IRNode } from "../normalize/ir.js";
import { isTextChild } from "../normalize/ir.js";
import type { MenuCapture } from "../capture/interactions.js";
/**
* M4b — mount-on-open menus (Radix-style portals). The panel is added to the DOM on open
* and removed on close, so the IR never sees it and the IR-based disclosure path can't
* reproduce it. Instead the panel is captured as a self-contained inline-styled fragment
* and reproduced CLIENT-SIDE: `DropdownMenu` renders null on mount and only injects the panel
* (under its trigger) on interaction — so the server-rendered base that gates 06 grade is
* structurally untouched (same safety model as DittoWire). A menu that doesn't reproduce is
* pruned by the interaction gate, so a broken menu is never shipped.
*/
const TRANSPARENT_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
export type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
function capToNode(ir: IR): Map<string, IRNode> {
const m = new Map<string, IRNode>();
const walk = (n: IRNode): void => {
const cap = n.attrs["data-cid-cap"];
if (cap !== undefined) m.set(cap, n);
for (const c of n.children) if (!isTextChild(c)) walk(c);
};
walk(ir.root);
return m;
}
function textContent(n: IRNode, max = 120): string {
let out = "";
const walk = (node: IRNode): void => {
if (out.length >= max) return;
for (const c of node.children) {
if (isTextChild(c)) out += " " + c.text;
else walk(c);
if (out.length >= max) return;
}
};
walk(n);
return out.replace(/\s+/g, " ").trim().slice(0, max);
}
function isLikelySkipTrigger(n: IRNode, menu: MenuCapture): boolean {
const label = `${textContent(n, 120)} ${n.attrs["aria-label"] ?? ""}`.toLowerCase();
const href = (n.attrs.href ?? "").trim();
if (/\bskip\s+(?:to\s+)?(?:content|main|navigation)\b/.test(label)) return true;
return href.startsWith("#") && menu.gap > 360;
}
/** Rewrite the captured panel HTML so the clone stays self-contained: same-origin links
* become app-relative (via the page's linkRewrite), and every image src resolves to a
* local asset or a transparent placeholder — never a remote origin (rubric Gate 2). */
function rewriteHtml(html: string, assetMap: Map<string, string>, sourceUrl: string, linkRewrite?: (href: string) => string): string {
const resolve = (u: string): string => { try { return new URL(u, sourceUrl).href; } catch { return u; } };
let out = html.replace(/\bsrc="([^"]*)"/g, (_m, u: string) => {
const local = assetMap.get(resolve(u));
return `src="${local ?? TRANSPARENT_GIF}"`;
});
if (linkRewrite) out = out.replace(/\bhref="([^"]*)"/g, (_m, u: string) => `href="${linkRewrite(u).replace(/"/g, "&quot;")}"`);
return out;
}
export function buildMenuSpecs(
ir: IR,
menus: MenuCapture[] | undefined,
assetMap: Map<string, string>,
sourceUrl: string,
linkRewrite?: (href: string) => string,
include?: (cid: string) => boolean,
): RTMenu[] {
if (!menus || !menus.length) return [];
const map = capToNode(ir);
const out: RTMenu[] = [];
for (const m of menus) {
const trigger = map.get(m.triggerCap);
if (!trigger) continue; // trigger pruned from the IR → drop
if (isLikelySkipTrigger(trigger, m)) continue;
if (include && !include(trigger.id)) continue;
out.push({ trigger: trigger.id, hoverOpen: m.hoverOpen, gap: m.gap, align: m.align, html: rewriteHtml(m.html, assetMap, sourceUrl, linkRewrite) });
}
return out;
}
export function menusJsx(menus: RTMenu[], indent: number): string {
if (!menus.length) return "";
const pad = " ".repeat(indent);
return `${pad}<DropdownMenu menus={${JSON.stringify(menus)}} />`;
}
export function dropdownMenuImportPath(depth: number): string {
return (depth === 0 ? "./" : "../".repeat(depth)) + "ditto/DropdownMenu";
}
/** The fixed DropdownMenu client component, written once per generated app. */
export const DROPDOWN_MENU_TSX = `"use client";
import { useEffect } from "react";
type RTMenu = { trigger: string; hoverOpen: boolean; gap: number; align: "left" | "right"; html: string };
const byCid = (cid: string): HTMLElement | null => document.querySelector('[data-cid="' + cid + '"]');
/** Reproduces mount-on-open dropdown/nav menus: renders nothing and applies NOTHING on mount; only on
* user interaction does it inject the captured panel fragment under its trigger. The base
* render is therefore unchanged. */
export default function DropdownMenu({ menus }: { menus: RTMenu[] }) {
useEffect(() => {
const ac = new AbortController();
const { signal } = ac;
const openPanels: HTMLElement[] = [];
for (const m of menus) {
const trig = byCid(m.trigger);
if (!trig) continue;
let panel: HTMLElement | null = null;
const place = () => {
if (!panel) return;
const r = trig.getBoundingClientRect();
panel.style.position = "absolute";
panel.style.top = (r.bottom + window.scrollY + m.gap) + "px";
if (m.align === "right") { panel.style.left = ""; panel.style.right = (document.documentElement.clientWidth - (r.right + window.scrollX)) + "px"; }
else { panel.style.right = ""; panel.style.left = (r.left + window.scrollX) + "px"; }
panel.style.zIndex = "9999";
};
const open = () => {
if (panel) return;
const wrap = document.createElement("div");
wrap.innerHTML = m.html;
panel = wrap.firstElementChild as HTMLElement | null;
if (!panel) return;
document.body.appendChild(panel);
openPanels.push(panel);
place();
trig.setAttribute("aria-expanded", "true");
};
const close = () => {
if (panel) {
const i = openPanels.indexOf(panel);
if (i !== -1) openPanels.splice(i, 1);
panel.remove();
panel = null;
}
trig.setAttribute("aria-expanded", "false");
};
const toggle = () => (panel ? close() : open());
if (m.hoverOpen) {
const root = trig.parentElement ?? trig;
root.addEventListener("mouseenter", open, { signal });
root.addEventListener("mouseleave", close, { signal });
} else {
trig.addEventListener("click", (e) => { e.preventDefault(); toggle(); }, { signal });
}
document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); }, { signal });
document.addEventListener("click", (e) => {
const t = e.target as Node;
if (panel && !trig.contains(t) && !panel.contains(t)) close();
}, { signal });
window.addEventListener("resize", place, { signal });
window.addEventListener("scroll", place, { passive: true, signal });
}
(window as any).__dittoMenuReady = true; // wiring done — lets the gate drive deterministically
return () => {
ac.abort();
// Remove any still-open panels appended to document.body so unmount leaves no orphan nodes.
for (const p of openPanels.splice(0)) p.remove();
};
}, [menus]);
return null;
}
`;