diff --git a/README.md b/README.md index 19a7380..6a785d9 100644 --- a/README.md +++ b/README.md @@ -497,6 +497,27 @@ class Blog { - Name and ID can be set explicitly (for readable query keys) - Works for multiple models and inheritance +#### Framework-agnostic model code + +If your models live in code that must not depend on React (a shared model +package, server-side or CLI code), import the decorator from the +`@mittwald/react-ghostmaker/model` entry point instead of the main entry: + +```ts +import { GhostMakerModel } from "@mittwald/react-ghostmaker/model"; +``` + +This entry point only contains what is needed to describe models — +`GhostMakerModel`, `ghostMakerModel`, `getModelName`, `getModelId` and the +`GhostMakerModelMeta` and `DynamicModel` types — and imports neither React nor +TanStack Query, not even for its type declarations. `react` and +`@tanstack/react-query` are therefore optional peer dependencies: you only need +them where you import from the main entry (`@mittwald/react-ghostmaker`), i.e. +where you actually create and use ghosts. + +Both entry points share the same model registry, so models decorated via +`/model` are recognized by ghosts created via the main entry. + #### Alternative: `registerModelIdentifier` (deprecated) The previous `registerModelIdentifier` function is still available but marked as deprecated. It can be used to centrally register IDs for models: @@ -861,6 +882,9 @@ Creates a ghost proxy from any object or class instance. - TanStack Query ^5 - TypeScript (recommended) +React and TanStack Query are only required for the main entry point, not for +[`@mittwald/react-ghostmaker/model`](#framework-agnostic-model-code). + ## 📄 License MIT © [Mittwald CM Service GmbH & Co. KG](https://github.com/mittwald) diff --git a/packages/react-ghostmaker/package.json b/packages/react-ghostmaker/package.json index 9b8725b..1faca35 100644 --- a/packages/react-ghostmaker/package.json +++ b/packages/react-ghostmaker/package.json @@ -27,6 +27,10 @@ "types": "./dist/types/index.d.ts", "import": "./dist/esm/index.js" }, + "./model": { + "types": "./dist/types/model.d.ts", + "import": "./dist/esm/model.js" + }, "./polytype": { "types": "./dist/types/polytype.d.ts", "import": "./dist/esm/polytype.js" @@ -90,8 +94,14 @@ "react": ">=19.2" }, "peerDependenciesMeta": { + "@tanstack/react-query": { + "optional": true + }, "polytype": { "optional": true + }, + "react": { + "optional": true } } } diff --git a/packages/react-ghostmaker/src/index.ts b/packages/react-ghostmaker/src/index.ts index 468de3d..a2c7933 100644 --- a/packages/react-ghostmaker/src/index.ts +++ b/packages/react-ghostmaker/src/index.ts @@ -4,5 +4,11 @@ export { type ReactGhost, UseGhostReturn } from "./types.ts"; export { getQueryContext, forwardQueryContext } from "./context.ts"; export { invalidateGhosts } from "./invalidate.ts"; export * from "./maybeGhost/index.ts"; -export { GhostMakerModel, ghostMakerModel } from "./metaData.ts"; -export { getModelId, getModelName } from "./queries.ts"; +export { + GhostMakerModel, + ghostMakerModel, + getModelId, + getModelName, + type DynamicModel, + type GhostMakerModelMeta, +} from "./metaData.ts"; diff --git a/packages/react-ghostmaker/src/metaData.ts b/packages/react-ghostmaker/src/metaData.ts index b6939e1..5b7f617 100644 --- a/packages/react-ghostmaker/src/metaData.ts +++ b/packages/react-ghostmaker/src/metaData.ts @@ -3,7 +3,7 @@ import type { AbstractClass, Class } from "type-fest"; type DecoratorTarget = Class | AbstractClass; -interface GhostMakerModelMeta { +export interface GhostMakerModelMeta { getId?: (instance: InstanceType) => string; name?: string; } @@ -36,7 +36,7 @@ export function GhostMakerModel( }; } -type DynamicModel = ( +export type DynamicModel = ( something: unknown, ) => GhostMakerModelMeta | void | undefined; @@ -66,6 +66,19 @@ export const getMetaData = ( ); }; +export const getModelName = (something: unknown) => { + return getMetaData(something)?.name; +}; + +export const getModelId = (something: unknown): string | undefined => { + if (is.object(something)) { + const getId = getMetaData(something)?.getId; + if (getId) { + return getId(something); + } + } +}; + const getMetaDataRecursive = ( something: unknown, collectedMeta?: Partial>, diff --git a/packages/react-ghostmaker/src/model.ts b/packages/react-ghostmaker/src/model.ts new file mode 100644 index 0000000..42af102 --- /dev/null +++ b/packages/react-ghostmaker/src/model.ts @@ -0,0 +1,8 @@ +export { + GhostMakerModel, + ghostMakerModel, + getModelId, + getModelName, + type DynamicModel, + type GhostMakerModelMeta, +} from "./metaData.ts"; diff --git a/packages/react-ghostmaker/src/queries.ts b/packages/react-ghostmaker/src/queries.ts index e4e06a5..07b977c 100644 --- a/packages/react-ghostmaker/src/queries.ts +++ b/packages/react-ghostmaker/src/queries.ts @@ -1,22 +1,9 @@ import is from "@sindresorhus/is"; import type { GhostChain, GhostChainItem, QueryKey } from "./types.ts"; import { hashObject } from "./hash.ts"; -import { getMetaData } from "./metaData.ts"; +import { getModelId, getModelName } from "./metaData.ts"; import { modelIdentifiers } from "./modelIdentifier.ts"; -export const getModelName = (something: unknown) => { - return getMetaData(something)?.name; -}; - -export const getModelId = (something: unknown): string | undefined => { - if (is.object(something)) { - const getId = getMetaData(something)?.getId; - if (getId) { - return getId(something); - } - } -}; - const getObjectName = (something: unknown) => { const modelName = getModelName(something); if (modelName) { diff --git a/packages/react-ghostmaker/src/reactFreeEntries.test.ts b/packages/react-ghostmaker/src/reactFreeEntries.test.ts new file mode 100644 index 0000000..70c6b8d --- /dev/null +++ b/packages/react-ghostmaker/src/reactFreeEntries.test.ts @@ -0,0 +1,55 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import ts from "typescript"; +import { describe, expect, test } from "vitest"; + +const srcDir = __dirname; + +const isReactPackage = (specifier: string) => + /^(react|react-dom|@tanstack\/react-query)(\/|$)/.test(specifier); + +const collectPackageImports = (entry: string): Map => { + const packageImports = new Map(); + const visited = new Set(); + const queue = [resolve(srcDir, entry)]; + + while (queue.length > 0) { + const file = queue.pop()!; + if (visited.has(file)) { + continue; + } + visited.add(file); + + const { importedFiles } = ts.preProcessFile( + readFileSync(file, "utf8"), + true, + true, + ); + + for (const { fileName: specifier } of importedFiles) { + if (specifier.startsWith(".")) { + queue.push(resolve(dirname(file), specifier)); + } else { + const importers = packageImports.get(specifier) ?? []; + importers.push(file.slice(srcDir.length + 1)); + packageImports.set(specifier, importers); + } + } + } + + return packageImports; +}; + +describe.each(["model.ts", "polytype.ts"])("entry point %s", (entry) => { + test("does not import React or TanStack Query, not even for types", () => { + const reactImports = Array.from(collectPackageImports(entry)).filter( + ([specifier]) => isReactPackage(specifier), + ); + + expect(Object.fromEntries(reactImports)).toEqual({}); + }); +}); + +test("the import walker detects React imports", () => { + expect(collectPackageImports("index.ts").has("react")).toBe(true); +});