#!/usr/bin/env node /** * Re-pull Motion Primitives from upstream into `components/`. * * The components are vendored rather than fetched at run time, for three * reasons measured in `docs/motion-command.md`: the whole set is 159 KB, * upstream changes about twice a year, and — the one that decides it — fetching * would pipe unreviewed third-party code into a user's project at generation * time. Vendored, an upstream change is a diff somebody reads. * * Run it, read the diff, commit it. That is the whole update process. */ import fs from "node:fs/promises"; import path from "node:path"; const RAW = "https://raw.githubusercontent.com/ibelick/motion-primitives/main"; const REGISTRY = `${RAW}/public/c`; const HERE = path.dirname(new URL(import.meta.url).pathname); /** * Changes we apply to every component, and why. * * These are not preferences. Upstream targets React 18, and Zelyq's template is * React 19 with `strict` and `noUnusedLocals` on, running `tsc` on every turn — * so an unpatched component fails the turn rather than merely looking untidy. */ const PATCHES = [ { name: "react-19-jsx-namespace", why: "React 19 removed the global JSX namespace; it lives on React.JSX now.", apply: (s) => s.replaceAll("keyof JSX.IntrinsicElements", "keyof React.JSX.IntrinsicElements"), }, { name: "react-19-motion-create-loses-props", why: "Once the cast above is right, `motion.create` still returns a component " + "whose props TypeScript cannot see, so `className` fails to assign. " + "Widening to ElementType restores it without pretending to know the tag.", apply: (s) => s.replace( /(const Motion\w+ = React\.useMemo\(\s*\(\) => motion\.create\([^)]*\),\s*\[\w+\]\s*\))(;)/g, "$1 as React.ElementType$2", ), }, { name: "react-19-unused-type-imports", why: "`noUnusedLocals` is on in the template and off upstream. React 19's JSX " + "transform means `React` no longer has to be in scope for JSX, and `JSX` " + "as a type import is unused once the namespace patch above lands — so " + "both become hard errors here and warnings nowhere else.", apply: (s) => { // Only when the file really does not use it. An earlier version stripped // `React` unconditionally and broke every component that calls // `React.Children` — the import was unused as a *value in JSX*, which is // not the same as unused. const usesReactValue = /\bReact\.[A-Za-z]/.test(s); let out = s; if (!usesReactValue) { out = out.replace(/^import React, \{([^}]*)\} from 'react';$/m, (m, rest) => /\w/.test(rest) ? `import {${rest}} from 'react';` : m, ); out = out.replace(/^import React from 'react';\n/m, ""); } out = out.replace(/^import \{ JSX \} from 'react';\n/m, ""); out = out.replace(/^import type \{ JSX \} from 'react';\n/m, ""); out = out.replace(/^import React, \{ JSX \} from 'react';$/m, "import React from 'react';"); out = out.replace(/^import \{ JSX, ([^}]*)\} from 'react';$/m, "import { $1} from 'react';"); out = out.replace(/^import \{([^}]*), JSX \} from 'react';$/m, "import {$1} from 'react';"); return out; }, }, { name: "react-19-type-only-jsx-import", why: "`import { type JSX }` is only there for `JSX.IntrinsicElements` in a " + "props type, which React 19 moved onto `React.JSX`. Left behind it is an " + "unused local, which `noUnusedLocals` rejects.", apply: (s) => { // `React.JSX` also contains "JSX", and the namespace patch above has // already rewritten every use into that form — so a naive search says the // import is still needed when it is not. const body = s.replace(/^import[^\n]*\n/gm, "").replace(/React\.JSX/g, ""); if (!/\bJSX\b/.test(body)) { return s .replace(/^import \{ type JSX, ([^}]*)\} from 'react';$/m, "import { $1} from 'react';") .replace(/^import \{([^}]*), type JSX \} from 'react';$/m, "import {$1} from 'react';") .replace( /^import React, \{([^}]*), type JSX \} from 'react';$/m, "import React, {$1} from 'react';", ) .replace(/^import \{ type JSX \} from 'react';\n/m, ""); } return s; }, }, { name: "react-19-nullable-refs", why: "React 19 types `useRef(null)` as `RefObject`. The hooks " + "these components pass those refs to declare `RefObject`, so every " + "call site fails. Widening the hook is right — a ref that is null before " + "mount is exactly what it has always received.", apply: (s) => s.replace(/ref: RefObject,/, "ref: RefObject,"), }, { name: "react-19-cloneelement-props", why: "`React.Children.map` gives `ReactElement` in React 19, so " + "spreading `child.props` is a spread of `unknown`. The cast says what the " + "code already assumes.", apply: (s) => s .replace(/\.\.\.child\.props,/g, "...(child.props as Record),") .replace( /\.\.\.\(child as [^)]*\)\.props,/g, "...(child.props as Record),", ), }, { name: "spring-type-widening", why: "`{ type: 'spring', … }` infers `type: string`, which does not narrow to " + "motion's Transition union under `strict`. Upstream gets away with it " + "because the object is inlined at a typed position; once it is a variable " + "or nested it is not.", apply: (s) => s.replace(/(\btype:\s*)'(spring|tween|keyframes|inertia)'/g, "$1'$2' as const"), }, { name: "motion-13-use-scroll-options", why: "`layoutEffect` was removed from UseScrollOptions after v11.", apply: (s) => s.replace(/^\s*layoutEffect:[^,\n]*,?\n/gm, ""), }, { name: "react-19-clone-element-props", why: "React 19 tightened `cloneElement`: the element from `Children.map` is " + "`ReactElement`, and cloning it with extra props no longer " + "matches an overload. Naming the props type says what the code already " + "does.", apply: (s) => s .replace( /cloneElement\(\s*child,/g, "cloneElement(child as React.ReactElement>,", ) .replace( /cloneElement\(child as React\.ReactElement,/g, "cloneElement(child as React.ReactElement>,", ) .replace( /\(child as React\.ReactElement\)\.props\.className/g, "(child as React.ReactElement<{ className?: string }>).props.className", ), }, { name: "drop-use-client", why: "A Next.js directive. Vite projects do not use it and it is dead weight.", apply: (s) => s.replace(/^'use client';\n/, ""), }, ]; /** * Fixes that are genuinely about one component. * * Each entry MUST still match, and the script fails if one does not — an * override that has silently stopped applying is how a vendored library ships * code nobody has looked at. */ const OVERRIDES = { "glow-effect": [ { why: "The `animations` record is a union of shapes, so indexing it does not narrow to what `animate` accepts.", from: "animate={animations[mode]}", to: "animate={animations[mode] as TargetAndTransition}", }, { why: "…and the cast above needs the type imported.", from: "import { motion, Transition } from 'motion/react';", to: "import { motion, Transition, type TargetAndTransition } from 'motion/react';", }, ], "in-view": [ { why: "AnimatedGroup takes a className and InView does not, which is an upstream inconsistency rather than a decision. Wrapping a section that carries layout classes is impossible without it, and the first real /motion pass hit that within minutes and patched its own copy — which the next add_motion would have overwritten.", from: " as?: React.ElementType;", to: " as?: React.ElementType;\n className?: string;", }, { why: "…and the prop has to be destructured and passed on.", from: " as = 'div',\n once\n}: InViewProps) {", to: " as = 'div',\n once,\n className,\n}: InViewProps) {", }, { why: "…onto the element itself.", from: " variants={variants}\n transition={transition}", to: " variants={variants}\n transition={transition}\n className={className}", }, ], "spinning-text": [ { why: "`finalTransition` is a union of object literals; `transition` wants the Transition union.", from: "transition={finalTransition}", to: "transition={finalTransition as Transition}", }, ], }; async function main() { const index = await fetch(`${REGISTRY}/registry.json`) .then((r) => r.json()) .catch(() => null); const names = index?.items?.map((i) => i.name); if (!names?.length) throw new Error("could not read the registry index"); await fs.mkdir(path.join(HERE, "components"), { recursive: true }); // Five components import these, and the registry does not carry them — its // entries only ever list one file. Without them those five do not resolve. await fs.mkdir(path.join(HERE, "hooks"), { recursive: true }); for (const hook of ["useClickOutside", "usePreventScroll"]) { const source = await (await fetch(`${RAW}/hooks/${hook}.tsx`)).text(); let patched = source; for (const patch of PATCHES) patched = patch.apply(patched); await fs.writeFile(path.join(HERE, "hooks", `${hook}.tsx`), patched); } const catalog = []; const applied = new Map(); for (const name of names.sort()) { const item = await (await fetch(`${REGISTRY}/${name}.json`)).json(); const file = item.files?.[0]; if (!file?.content) { console.warn(` ${name}: no inlined source, skipped`); continue; } let source = file.content; for (const patch of PATCHES) { const after = patch.apply(source); if (after !== source) applied.set(patch.name, (applied.get(patch.name) ?? 0) + 1); source = after; } for (const override of OVERRIDES[name] ?? []) { if (!source.includes(override.from)) { throw new Error( `${name}: override no longer matches — upstream changed.\n looking for: ${override.from}\n reason: ${override.why}`, ); } source = source.replace(override.from, override.to); applied.set(`override:${name}`, (applied.get(`override:${name}`) ?? 0) + 1); } await fs.writeFile(path.join(HERE, "components", `${name}.tsx`), source); // Which hooks the component imports. The registry never says — its entries // list one file — and a component whose hook is missing does not resolve. const hooks = [...source.matchAll(/from '@\/hooks\/(\w+)'/g)].map((m) => m[1]); catalog.push({ name, dependencies: item.dependencies ?? [], hooks: [...new Set(hooks)], lines: source.split("\n").length, }); } // Short arrays on one line, matching what this repo's formatter would print — // so a refresh does not leave the tree failing its own lint. const json = JSON.stringify({ source: index.homepage, components: catalog }, null, 2).replace( /\[\n\s+((?:"[^"]*",?\s*)+)\n\s+\]/g, (_, inner) => `[${inner.trim().replace(/,\s+/g, ", ")}]`, ); await fs.writeFile(path.join(HERE, "catalog.json"), `${json}\n`); console.log(`${catalog.length} components`); for (const [name, n] of applied) console.log(` patched ${n}× — ${name}`); } await main();