Ctrl K
ComunidadGuías…

motion-primitives/refresh.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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<T>(null)` as `RefObject<T | null>`. The hooks " +
      "these components pass those refs to declare `RefObject<T>`, 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<T>,/, "ref: RefObject<T | null>,"),
  },
  {
    name: "react-19-cloneelement-props",
    why:
      "`React.Children.map` gives `ReactElement<unknown>` 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<string, unknown>),")
        .replace(
          /\.\.\.\(child as [^)]*\)\.props,/g,
          "...(child.props as Record<string, unknown>),",
        ),
  },
  {
    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<unknown>`, 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<Record<string, unknown>>,",
        )
        .replace(
          /cloneElement\(child as React\.ReactElement,/g,
          "cloneElement(child as React.ReactElement<Record<string, unknown>>,",
        )
        .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();