diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ca07fb..6dd830b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.3.1 + +- fix: the plugin's exported API (`contentCliPlugin`, `toFieldSchemas`, `getEntitySchema`, `getBlockSchema`, `listReadableEntities`) is now typed against Payload's own `Config`/`Field`/`PayloadRequest` shapes instead of `any`, so consumers get type-checking and autocomplete. `payload` is declared as an optional peer dependency and imported type-only — nothing is added to the runtime. + ## 0.3.0 - feat: the plugin entry now exports the schema API for building custom tools (e.g. `listEntities` + `getEntitySchema` + `getBlockSchema` MCP tools) without going through HTTP. `listReadableEntities({ req })` returns the readable collection/global slugs plus localization; `getEntitySchema({ req, type, slug })` returns the same `{ slug, fields, jsonSchema }` the `/schema` endpoint produces for one entity; `getBlockSchema({ req, slugs })` resolves richText block slugs to `{ slug, fields }`. diff --git a/package.json b/package.json index 00f9606..14c4437 100644 --- a/package.json +++ b/package.json @@ -66,6 +66,14 @@ "dotenv": "^17.4.2", "zod": "^4.4.3" }, + "peerDependencies": { + "payload": "^3.0.0" + }, + "peerDependenciesMeta": { + "payload": { + "optional": true + } + }, "devDependencies": { "@commitlint/cli": "^20.5.0", "@commitlint/config-conventional": "^20.5.0", @@ -75,6 +83,7 @@ "lint-staged": "^17.0.5", "oxfmt": "^0.50.0", "oxlint": "^1.65.0", + "payload": "3.84.1", "tsx": "^4.22.0", "typescript": "^6.0.3", "vitest": "4.1.6" diff --git a/plans/012-type-payload-shapes-drop-any.md b/plans/012-type-payload-shapes-drop-any.md deleted file mode 100644 index 96e04eb..0000000 --- a/plans/012-type-payload-shapes-drop-any.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -title: Replace structural `any` with typed Payload shapes -type: refactor -status: draft ---- - -# Replace structural `any` with typed Payload shapes - -Every plugin module (`index.ts`, `schemaApi.ts`, `fields.ts`, `lexical.ts`, -`jsonSchema.ts`) opens with `/* eslint-disable @typescript-eslint/no-explicit-any */`. -The `any` is deliberate — the file headers state types are kept inline "to avoid a -hard dependency on `payload`." We type Payload's config, fields, editor, and `req` -as `any` so the plugin never `import`s from `payload` / `@payloadcms/richtext-lexical`. - -Dropping the disable means choosing one of two tradeoffs: - -- **Option A — type-only dependency.** Import `Field`, `SanitizedConfig`, - `PayloadRequest`, and the lexical feature types from `payload` / - `@payloadcms/richtext-lexical` as `import type` only. Cleanest types, but - reverses the stated "no hard dependency" decision (even type-only imports add a - version-coupled devDependency and can break across Payload majors). -- **Option B — minimal structural interfaces.** Hand-write interfaces for just the - slices of Payload config the plugin touches (field nodes, editor config, - `req.payload.config`). Keeps zero runtime/type dependency, but is real work and a - standing drift risk against Payload's real shapes on every upgrade. - -This is a design tradeoff, not a mechanical cleanup — needs an explicit decision -before implementing. Keep it out of unrelated diffs so it stays reviewable on its -own. No user-visible behavior change either way. - -## Open question - -Which option, A or B? (Leaning B to preserve the no-dependency design, accepting the -maintenance cost.) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9b03da3..77cecd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: oxlint: specifier: ^1.65.0 version: 1.65.0 + payload: + specifier: 3.84.1 + version: 3.84.1(graphql@16.14.0)(typescript@6.0.3) tsx: specifier: ^4.22.0 version: 4.22.0 diff --git a/src/__tests__/cli-process.test.ts b/src/__tests__/cli-process.test.ts index b7a1ae0..70c1390 100644 --- a/src/__tests__/cli-process.test.ts +++ b/src/__tests__/cli-process.test.ts @@ -40,7 +40,9 @@ describe.skipIf(!hasRemoteEnv)("cli (remote)", () => { it("push exits with code 2 on conflict", async () => { const dir = path.join(CONTENT_DIR, "collections", "posts"); - const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".json") && !f.startsWith("_")); + const files = (await fs.readdir(dir)).filter( + (file) => file.endsWith(".json") && !file.startsWith("_"), + ); expect(files.length).toBeGreaterThan(0); const target = path.join(dir, files[0]); @@ -79,7 +81,7 @@ describe.skipIf(!hasRemoteEnv)("cli (remote)", () => { expect(localized.status, localized.stderr).toBe(0); const dir = path.join(CONTENT_DIR, "collections", "posts"); - const enFiles = (await fs.readdir(dir)).filter((f) => f.endsWith("_en.json")); + const enFiles = (await fs.readdir(dir)).filter((file) => file.endsWith("_en.json")); expect(enFiles.length).toBeGreaterThan(1); // Locally edit one file before the next pull @@ -96,7 +98,7 @@ describe.skipIf(!hasRemoteEnv)("cli (remote)", () => { expect(repulled.stdout).toMatch(/Kept 1 orphan file/); // Clean orphans (non-edited) gone, edited orphan preserved - const remaining = (await fs.readdir(dir)).filter((f) => f.endsWith("_en.json")); + const remaining = (await fs.readdir(dir)).filter((file) => file.endsWith("_en.json")); expect(remaining).toEqual([enFiles[0]]); }); diff --git a/src/__tests__/integration.test.ts b/src/__tests__/integration.test.ts index 0bb2cc2..b77c45c 100644 --- a/src/__tests__/integration.test.ts +++ b/src/__tests__/integration.test.ts @@ -24,7 +24,7 @@ async function cleanup() { async function readJsonDir(dir: string): Promise { const entries = await fs.readdir(dir); - return entries.filter((f) => f.endsWith(".json") && !f.startsWith("_")); + return entries.filter((file) => file.endsWith(".json") && !file.startsWith("_")); } async function readJson(filePath: string): Promise> { @@ -72,7 +72,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { expect(schema.slug).toBe("posts"); const fields = schema.fields as Array<{ name: string; type: string }>; - const titleField = fields.find((f) => f.name === "title"); + const titleField = fields.find((field) => field.name === "title"); expect(titleField).toBeDefined(); expect(titleField!.type).toBe("text"); }); @@ -84,13 +84,13 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { virtual?: boolean; }>; - const pathField = fields.find((f) => f.name === "path"); + const pathField = fields.find((field) => field.name === "path"); expect(pathField?.virtual).toBe(true); - const breadcrumbsField = fields.find((f) => f.name === "breadcrumbs"); + const breadcrumbsField = fields.find((field) => field.name === "breadcrumbs"); expect(breadcrumbsField?.virtual).toBe(true); - const titleField = fields.find((f) => f.name === "title"); + const titleField = fields.find((field) => field.name === "title"); expect(titleField?.virtual).toBeUndefined(); }); @@ -208,7 +208,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { const result = await status(config); expect(result!.added.length).toBeGreaterThanOrEqual(1); - expect(result!.added.some((a) => a.includes("test-new-status"))).toBe(true); + expect(result!.added.some((addedPath) => addedPath.includes("test-new-status"))).toBe(true); await fs.unlink(newFile); }); @@ -291,7 +291,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { limit: 100, }); const created = response.docs.find( - (d) => (d as Record).slug === "push-create-test", + (doc) => (doc as Record).slug === "push-create-test", ) as Record | undefined; expect(created).toBeDefined(); expect(created!.name).toBe("Push Create Test"); @@ -347,8 +347,8 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { // After push, manifest is updated — but note the file content still differs // from what the server returns (we wrote it, server may add fields) const manifest = await loadManifest(CONTENT_DIR); - const key = Object.keys(manifest!.documents).find((k) => - k.includes(files[0].replace(".json", "")), + const key = Object.keys(manifest!.documents).find((candidate) => + candidate.includes(files[0].replace(".json", "")), ); expect(key).toBeDefined(); expect(manifest!.documents[key!].updatedAt).toBeTruthy(); @@ -394,12 +394,14 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { expect(Array.isArray(schema!.endpoints)).toBe(true); // The example project registers /example-plugin/stats (GET) and /example-plugin/publish-all (POST) - const stats = schema!.endpoints!.find((ep) => ep.path === "/api/example-plugin/stats"); + const stats = schema!.endpoints!.find( + (endpoint) => endpoint.path === "/api/example-plugin/stats", + ); expect(stats).toBeDefined(); expect(stats!.method).toBe("get"); const publishAll = schema!.endpoints!.find( - (ep) => ep.path === "/api/example-plugin/publish-all", + (endpoint) => endpoint.path === "/api/example-plugin/publish-all", ); expect(publishAll).toBeDefined(); expect(publishAll!.method).toBe("post"); @@ -411,7 +413,9 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { endpoints?: { path: string }[]; } | null; - const schemaEndpoint = schema!.endpoints!.find((ep) => ep.path === "/api/content-cli/schema"); + const schemaEndpoint = schema!.endpoints!.find( + (endpoint) => endpoint.path === "/api/content-cli/schema", + ); expect(schemaEndpoint).toBeUndefined(); }); @@ -538,8 +542,9 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { depth: 0, }); const originalVersion = (versions.docs as Record[]).find( - (v) => - ((v.version as Record).excerpt as string) === "version-test-original", + (version) => + ((version.version as Record).excerpt as string) === + "version-test-original", ); expect(originalVersion).toBeDefined(); @@ -613,7 +618,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { const files = await readJsonDir(postsDir); // Files should have _de suffix - expect(files.every((f) => f.endsWith("_de.json"))).toBe(true); + expect(files.every((file) => file.endsWith("_de.json"))).toBe(true); const post = await readJson(path.join(postsDir, files[0])); // Localized fields must be flat strings @@ -627,8 +632,8 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { const postsDir = path.join(CONTENT_DIR, "collections", "posts"); const files = await readJsonDir(postsDir); - const enFiles = files.filter((f) => f.endsWith("_en.json")); - const deFiles = files.filter((f) => f.endsWith("_de.json")); + const enFiles = files.filter((file) => file.endsWith("_en.json")); + const deFiles = files.filter((file) => file.endsWith("_de.json")); expect(enFiles.length).toBeGreaterThan(0); expect(deFiles.length).toBeGreaterThan(0); expect(enFiles.length).toBe(deFiles.length); @@ -640,7 +645,7 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { const manifest = await loadManifest(CONTENT_DIR); const keys = Object.keys(manifest!.documents); - expect(keys.every((k) => k.endsWith("_de.json"))).toBe(true); + expect(keys.every((key) => key.endsWith("_de.json"))).toBe(true); }); it("omits locale from filenames and manifest keys when not specified", async () => { @@ -650,11 +655,15 @@ describe.skipIf(!hasRemoteEnv)("integration", () => { const postsDir = path.join(CONTENT_DIR, "collections", "posts"); const files = await readJsonDir(postsDir); // No locale suffix - expect(files.every((f) => !f.includes("_en.json") && !f.includes("_de.json"))).toBe(true); + expect(files.every((file) => !file.includes("_en.json") && !file.includes("_de.json"))).toBe( + true, + ); const manifest = await loadManifest(CONTENT_DIR); const keys = Object.keys(manifest!.documents); - expect(keys.every((k) => !k.includes("_en.json") && !k.includes("_de.json"))).toBe(true); + expect(keys.every((key) => !key.includes("_en.json") && !key.includes("_de.json"))).toBe( + true, + ); }); }); }); // describe.skipIf diff --git a/src/__tests__/profiles.test.ts b/src/__tests__/profiles.test.ts index 7f43db2..83b4502 100644 --- a/src/__tests__/profiles.test.ts +++ b/src/__tests__/profiles.test.ts @@ -352,8 +352,8 @@ describe("loadConfig with profile", () => { apiKey: "profile-key", }); - const apiKeyWarnings = warnSpy.mock.calls.filter((c) => - String(c[0]).includes("PAYLOAD_API_KEY"), + const apiKeyWarnings = warnSpy.mock.calls.filter((call) => + String(call[0]).includes("PAYLOAD_API_KEY"), ); expect(apiKeyWarnings.length).toBe(1); } finally { @@ -371,8 +371,8 @@ describe("loadConfig with profile", () => { const { loadConfig } = await import("../config.js"); loadConfig(undefined, { apiKey: "same-key" }); - const apiKeyWarnings = warnSpy.mock.calls.filter((c) => - String(c[0]).includes("PAYLOAD_API_KEY"), + const apiKeyWarnings = warnSpy.mock.calls.filter((call) => + String(call[0]).includes("PAYLOAD_API_KEY"), ); expect(apiKeyWarnings.length).toBe(0); } finally { diff --git a/src/cli.ts b/src/cli.ts index 5727edb..764de23 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -76,7 +76,7 @@ program Utilities: ["me", "discover", "skill", "lexical", "clean", "profile"], }; - const cmds = new Map(cmd.commands.map((c: Command) => [c.name(), c])); + const cmds = new Map(cmd.commands.map((command: Command) => [command.name(), command])); let output = `Usage: ${helper.commandUsage(cmd)}\n\n`; output += `${cmd.description()}\n`; @@ -188,13 +188,13 @@ program const localWhere: Record = {}; if (opts.where) { const parsed = parseWhere(opts.where as string); - for (const [k, v] of Object.entries(parsed)) { - if (typeof v === "object" && v !== null) { - const inner = v as Record; - const val = inner.equals ?? inner.like ?? Object.values(inner)[0]; - if (val !== undefined) localWhere[k] = String(val); + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === "object" && value !== null) { + const inner = value as Record; + const resolvedValue = inner.equals ?? inner.like ?? Object.values(inner)[0]; + if (resolvedValue !== undefined) localWhere[key] = String(resolvedValue); } else { - localWhere[k] = String(v); + localWhere[key] = String(value); } } } @@ -314,13 +314,15 @@ program // Bulk update via PATCH with where params const where = parseWhere(opts.where as string); const whereParams: Record = {}; - for (const [k, v] of Object.entries(where)) { - if (typeof v === "object" && v !== null) { - for (const [op, val] of Object.entries(v as Record)) { - whereParams[`where[${k}][${op}]`] = String(val); + for (const [key, value] of Object.entries(where)) { + if (typeof value === "object" && value !== null) { + for (const [operator, operatorValue] of Object.entries( + value as Record, + )) { + whereParams[`where[${key}][${operator}]`] = String(operatorValue); } } else { - whereParams[`where[${k}][equals]`] = String(v); + whereParams[`where[${key}][equals]`] = String(value); } } result = await client.rawPatch( @@ -553,8 +555,8 @@ program withFileTypes: true, }); files = entries - .filter((e) => e.isFile()) - .map((e) => path.join(opts.dir as string, e.name)) + .filter((entry) => entry.isFile()) + .map((entry) => path.join(opts.dir as string, entry.name)) .sort(); } else { files = []; diff --git a/src/client.ts b/src/client.ts index 24e4606..331d53d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -74,7 +74,7 @@ const CLOUD_STORAGE_REQUIRED_SELECT_KEYS = ["filename", "mimeType", "filesize", function preserveUploadFieldsInSelect(select: SelectType | undefined): SelectType | undefined { if (!select) return select; const values = Object.values(select); - const isExcludeMode = values.length > 0 && values.every((v) => v === false); + const isExcludeMode = values.length > 0 && values.every((value) => value === false); if (isExcludeMode) { const next: Record = {}; diff --git a/src/find.ts b/src/find.ts index 4bb8dea..8014d79 100644 --- a/src/find.ts +++ b/src/find.ts @@ -64,7 +64,7 @@ async function scanDir( const fields: Record = {}; if (options.select) { - const isExclude = Object.values(options.select).every((v) => !v); + const isExclude = Object.values(options.select).every((selected) => !selected); if (isExclude) { for (const key of Object.keys(doc)) { if (!(key in options.select)) { diff --git a/src/keychain.ts b/src/keychain.ts index 349ca6e..3872f15 100644 --- a/src/keychain.ts +++ b/src/keychain.ts @@ -3,9 +3,9 @@ import { promisify } from "node:util"; const execFileP = promisify(execFile); -function shellQuote(s: string): string { +function shellQuote(value: string): string { // POSIX single-quote escape — safe for sh -c invocations on macOS/Linux. - return `'${s.replace(/'/g, `'\\''`)}'`; + return `'${value.replace(/'/g, `'\\''`)}'`; } /** diff --git a/src/lexical/__tests__/validate.test.ts b/src/lexical/__tests__/validate.test.ts index b70ab3e..d42d873 100644 --- a/src/lexical/__tests__/validate.test.ts +++ b/src/lexical/__tests__/validate.test.ts @@ -42,7 +42,7 @@ describe("validateTree", () => { ]; const warnings = validateTree(children); expect(warnings.length).toBeGreaterThan(0); - expect(warnings.some((w) => w.startsWith("[0.0]"))).toBe(true); + expect(warnings.some((warning) => warning.startsWith("[0.0]"))).toBe(true); }); it("returns empty for empty array", () => { diff --git a/src/lexical/address.ts b/src/lexical/address.ts index a7d63d7..a5a538e 100644 --- a/src/lexical/address.ts +++ b/src/lexical/address.ts @@ -8,11 +8,11 @@ export function parseAddress(str: string): Address { const parts = trimmed.split("."); const address: Address = []; for (const part of parts) { - const n = Number(part); - if (!Number.isInteger(n) || n < 0) { + const segment = Number(part); + if (!Number.isInteger(segment) || segment < 0) { throw new Error(`Invalid address segment "${part}" — must be a non-negative integer`); } - address.push(n); + address.push(segment); } return address; } diff --git a/src/lexical/field-path.ts b/src/lexical/field-path.ts index b4fdf1d..15f0d4d 100644 --- a/src/lexical/field-path.ts +++ b/src/lexical/field-path.ts @@ -26,10 +26,10 @@ function getByPath(obj: Record, path: string): unknown { */ function findBlockByType(children: LexicalNode[], blockType: string): LexicalNode | undefined { return children.find( - (n) => - n.type === "block" && - (n as Record).fields && - ((n as Record).fields as Record).blockType === blockType, + (node) => + node.type === "block" && + (node as Record).fields && + ((node as Record).fields as Record).blockType === blockType, ); } @@ -119,7 +119,7 @@ export function autoDetectLexicalField(doc: Record): { } if (candidates.length > 1) { - const names = candidates.map((c) => c.path).join(", "); + const names = candidates.map((candidate) => candidate.path).join(", "); throw new Error( `Multiple Lexical richtext fields found: ${names}. Use --field to specify which one.`, ); diff --git a/src/lexical/index.ts b/src/lexical/index.ts index fa847b4..9dcb989 100644 --- a/src/lexical/index.ts +++ b/src/lexical/index.ts @@ -377,36 +377,46 @@ export function registerLexicalCommands(program: Command): void { const sourceLinks = extractLinks(sourceChildren); const targetLinks = extractLinks(targetChildren); - const targetLinkKeys = new Set(targetLinks.map((l) => `${l.relationTo}:${l.value}`)); - const sourceLinkKeys = new Set(sourceLinks.map((l) => `${l.relationTo}:${l.value}`)); + const targetLinkKeys = new Set( + targetLinks.map((link) => `${link.relationTo}:${link.value}`), + ); + const sourceLinkKeys = new Set( + sourceLinks.map((link) => `${link.relationTo}:${link.value}`), + ); const onlyInSource = sourceLinks.filter( - (l) => !targetLinkKeys.has(`${l.relationTo}:${l.value}`), + (link) => !targetLinkKeys.has(`${link.relationTo}:${link.value}`), ); const onlyInTarget = targetLinks.filter( - (l) => !sourceLinkKeys.has(`${l.relationTo}:${l.value}`), + (link) => !sourceLinkKeys.has(`${link.relationTo}:${link.value}`), + ); + const inBoth = sourceLinks.filter((link) => + targetLinkKeys.has(`${link.relationTo}:${link.value}`), ); - const inBoth = sourceLinks.filter((l) => targetLinkKeys.has(`${l.relationTo}:${l.value}`)); // Compare blocks const sourceBlocks = extractBlocks(sourceChildren); const targetBlocks = extractBlocks(targetChildren); - const targetBlockTypes = new Set(targetBlocks.map((b) => b.blockType)); - const sourceBlockTypes = new Set(sourceBlocks.map((b) => b.blockType)); + const targetBlockTypes = new Set(targetBlocks.map((block) => block.blockType)); + const sourceBlockTypes = new Set(sourceBlocks.map((block) => block.blockType)); - const blocksOnlyInSource = sourceBlocks.filter((b) => !targetBlockTypes.has(b.blockType)); - const blocksOnlyInTarget = targetBlocks.filter((b) => !sourceBlockTypes.has(b.blockType)); + const blocksOnlyInSource = sourceBlocks.filter( + (block) => !targetBlockTypes.has(block.blockType), + ); + const blocksOnlyInTarget = targetBlocks.filter( + (block) => !sourceBlockTypes.has(block.blockType), + ); // For each missing link, check if the text exists in the target type LinkWithMatch = (typeof onlyInSource)[number] & { match?: string; }; - const onlyInSourceWithMatches: LinkWithMatch[] = onlyInSource.map((l) => { + const onlyInSourceWithMatches: LinkWithMatch[] = onlyInSource.map((link) => { // Try exact match first - const exactMatches = searchText(targetChildren, l.text); + const exactMatches = searchText(targetChildren, link.text); if (exactMatches.length > 0) { - return { ...l, match: l.text }; + return { ...link, match: link.text }; } // Try significant words (4+ chars, longest first, split on spaces and hyphens) const stopWords = new Set([ @@ -449,17 +459,17 @@ export function registerLexicalCommands(program: Command): void { "was", "wir", ]); - const words = l.text + const words = link.text .split(/[\s\-–]+/) - .filter((w) => w.length >= 4 && !stopWords.has(w.toLowerCase())) + .filter((word) => word.length >= 4 && !stopWords.has(word.toLowerCase())) .sort((a, b) => b.length - a.length); for (const word of words) { const wordMatches = searchText(targetChildren, word); if (wordMatches.length > 0) { - return { ...l, match: word }; + return { ...link, match: word }; } } - return l; + return link; }); // Output diff --git a/src/lexical/validate.ts b/src/lexical/validate.ts index 2eedab8..377f610 100644 --- a/src/lexical/validate.ts +++ b/src/lexical/validate.ts @@ -27,7 +27,9 @@ export function validateTree(children: LexicalNode[], prefix: string = ""): stri } if (Array.isArray(node.children)) { - validateTree(node.children as LexicalNode[], addr).forEach((w) => warnings.push(w)); + validateTree(node.children as LexicalNode[], addr).forEach((warning) => + warnings.push(warning), + ); } } diff --git a/src/plugin/__tests__/contentCliPlugin.test.ts b/src/plugin/__tests__/contentCliPlugin.test.ts index bfc18ef..8dda94c 100644 --- a/src/plugin/__tests__/contentCliPlugin.test.ts +++ b/src/plugin/__tests__/contentCliPlugin.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { contentCliPlugin } from "../index.js"; +import { contentCliPlugin as contentCliPluginStrict } from "../index.js"; + +// Tests drive the plugin with partial Payload config/request mocks; loosen the +// config input and result shape here. The strict signature (Config -> Config) +// is exercised by the plugin source under `tsc`. +const contentCliPlugin = contentCliPluginStrict as unknown as ( + options?: Parameters[0], +) => (config: unknown) => { endpoints: any[] }; describe("contentCliPlugin endpoint metadata", () => { it("resolves full paths and captures custom metadata", async () => { @@ -54,7 +61,9 @@ describe("contentCliPlugin endpoint metadata", () => { }; const result = plugin(config); - const schemaEndpoint = result.endpoints.find((ep: any) => ep.path === "/content-cli/schema"); + const schemaEndpoint = result.endpoints.find( + (endpoint: any) => endpoint.path === "/content-cli/schema", + ); const mockReq = { user: { id: 1 }, @@ -99,7 +108,9 @@ describe("contentCliPlugin endpoint metadata", () => { it("returns 401 when the request has no authenticated user", async () => { const plugin = contentCliPlugin(); const result = plugin({ endpoints: [] }); - const schemaEndpoint = result.endpoints.find((ep: any) => ep.path === "/content-cli/schema"); + const schemaEndpoint = result.endpoints.find( + (endpoint: any) => endpoint.path === "/content-cli/schema", + ); const response = await schemaEndpoint.handler({ user: null, payload: {} }); expect(response.status).toBe(401); @@ -111,7 +122,9 @@ describe("contentCliPlugin endpoint metadata", () => { access: (req: any) => req.headers?.get("x-secret") === "open-sesame", }); const result = plugin({ endpoints: [] }); - const schemaEndpoint = result.endpoints.find((ep: any) => ep.path === "/content-cli/schema"); + const schemaEndpoint = result.endpoints.find( + (endpoint: any) => endpoint.path === "/content-cli/schema", + ); // denied — wrong secret const denied = await schemaEndpoint.handler({ @@ -141,7 +154,9 @@ describe("contentCliPlugin endpoint metadata", () => { }, }); const result = plugin({ endpoints: [] }); - const schemaEndpoint = result.endpoints.find((ep: any) => ep.path === "/content-cli/schema"); + const schemaEndpoint = result.endpoints.find( + (endpoint: any) => endpoint.path === "/content-cli/schema", + ); const mockReq = { user: null, @@ -166,7 +181,7 @@ describe("contentCliPlugin per-entity read access", () => { function getSchemaEndpoint(config: any) { const plugin = contentCliPlugin(); const result = plugin(config); - return result.endpoints.find((ep: any) => ep.path === "/content-cli/schema"); + return result.endpoints.find((endpoint: any) => endpoint.path === "/content-cli/schema"); } it("omits collections whose access.read returns false", async () => { @@ -301,7 +316,7 @@ describe("contentCliPlugin per-entity read access", () => { }) ).json(); - const paths = body.endpoints.map((e: any) => e.path); + const paths = body.endpoints.map((endpoint: any) => endpoint.path); expect(paths).toContain("/api/stats"); expect(paths).toContain("/api/posts/publish"); expect(paths).not.toContain("/api/secrets/leak"); @@ -337,7 +352,7 @@ describe("contentCliPlugin per-entity read access", () => { }) ).json(); - const paths = body.endpoints.map((e: any) => e.path); + const paths = body.endpoints.map((endpoint: any) => endpoint.path); expect(paths).toContain("/api/globals/settings/refresh"); expect(paths).not.toContain("/api/globals/hidden/peek"); }); diff --git a/src/plugin/__tests__/getBlockSchema.test.ts b/src/plugin/__tests__/getBlockSchema.test.ts index c83d259..61a3b27 100644 --- a/src/plugin/__tests__/getBlockSchema.test.ts +++ b/src/plugin/__tests__/getBlockSchema.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect } from "vitest"; +import type { PayloadRequest } from "payload"; import { getBlockSchema } from "../index.js"; /** Build a mock PayloadRequest carrying top-level block definitions. */ -function mockReq(blocks: any[], user: any = { id: 1 }) { - return { user, payload: { config: { blocks } } }; +function mockReq(blocks: any[], user: any = { id: 1 }): PayloadRequest { + return { user, payload: { config: { blocks } } } as unknown as PayloadRequest; } describe("getBlockSchema", () => { @@ -28,7 +29,7 @@ describe("getBlockSchema", () => { ]); const result = await getBlockSchema({ req, slugs: ["b", "a"] }); - expect(result.map((r) => r.slug)).toEqual(["b", "a"]); + expect(result.map((block) => block.slug)).toEqual(["b", "a"]); }); it("resolves nested blockReferences within a block via the shared map", async () => { diff --git a/src/plugin/__tests__/getEntitySchema.test.ts b/src/plugin/__tests__/getEntitySchema.test.ts index 18b724b..75b8365 100644 --- a/src/plugin/__tests__/getEntitySchema.test.ts +++ b/src/plugin/__tests__/getEntitySchema.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from "vitest"; +import type { PayloadRequest } from "payload"; import { getEntitySchema } from "../index.js"; /** Build a mock PayloadRequest carrying a config and an optional user. */ function mockReq( config: { collections?: any[]; globals?: any[]; blocks?: any[] }, user: any = { id: 1 }, -) { +): PayloadRequest { return { user, payload: { @@ -15,7 +16,7 @@ function mockReq( blocks: config.blocks ?? [], }, }, - }; + } as unknown as PayloadRequest; } describe("getEntitySchema", () => { diff --git a/src/plugin/__tests__/lexical.test.ts b/src/plugin/__tests__/lexical.test.ts index fea07c3..20c5c9f 100644 --- a/src/plugin/__tests__/lexical.test.ts +++ b/src/plugin/__tests__/lexical.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from "vitest"; -import { toFieldSchemas } from "../index.js"; +import { toFieldSchemas as toFieldSchemasStrict, type FieldSchema } from "../index.js"; + +// Tests pass deliberately-partial field fixtures (and synthetic lexical editor +// shapes); loosen the input types here. The strict signature is exercised by +// the plugin source under `tsc`. +const toFieldSchemas = toFieldSchemasStrict as unknown as ( + fields: unknown[], + blocksBySlug?: Record, +) => FieldSchema[]; /** * A lexical editor as it appears on a sanitized Payload config: an object with @@ -19,13 +27,13 @@ function resolvedEditor( features: { key: string; sanitizedServerFeatureProps?: unknown; nodeTypes?: string[] }[], ) { const resolvedFeatureMap = new Map( - features.map((f) => [ - f.key, + features.map((feature) => [ + feature.key, { - sanitizedServerFeatureProps: f.sanitizedServerFeatureProps, + sanitizedServerFeatureProps: feature.sanitizedServerFeatureProps, // Mirror Payload's resolved-feature `nodes` shape: each entry is // `{ node }` where `node.getType()` returns the node's `type` string. - nodes: (f.nodeTypes ?? []).map((type) => ({ node: { getType: () => type } })), + nodes: (feature.nodeTypes ?? []).map((type) => ({ node: { getType: () => type } })), }, ]), ); diff --git a/src/plugin/__tests__/toFieldSchemas.test.ts b/src/plugin/__tests__/toFieldSchemas.test.ts index 75b9c72..723dcc5 100644 --- a/src/plugin/__tests__/toFieldSchemas.test.ts +++ b/src/plugin/__tests__/toFieldSchemas.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { toFieldSchemas } from "../index.js"; +import { toFieldSchemas as toFieldSchemasStrict, type FieldSchema } from "../index.js"; + +// Tests pass deliberately-partial field fixtures; loosen the input types here. +// The strict signature is exercised by the plugin source under `tsc`. +const toFieldSchemas = toFieldSchemasStrict as unknown as ( + fields: unknown[], + blocksBySlug?: Record, +) => FieldSchema[]; describe("toFieldSchemas", () => { it("extracts basic named fields", () => { diff --git a/src/plugin/fields.ts b/src/plugin/fields.ts index eb3f614..0747a66 100644 --- a/src/plugin/fields.ts +++ b/src/plugin/fields.ts @@ -7,10 +7,11 @@ * and `blockReferences` are resolved against the shared block map. Each * `richText` field carries a `lexicalFeatures` summary (see `./lexical.ts`). * - * Types are kept inline to avoid a hard dependency on `payload`. + * Uses type-only imports from `payload`, so the package never pulls Payload in + * at runtime. */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Block, Field, Tab } from "payload"; import { extractLexicalSummary } from "./lexical.js"; import type { LexicalFeatureSummary } from "./lexical.js"; @@ -30,22 +31,50 @@ export interface FieldSchema { lexicalFeatures?: LexicalFeatureSummary; } -// Alternative: import { flattenTopLevelFields } from 'payload/utilities/flattenTopLevelFields' -// with moveSubFieldsToTop: true — but that adds a hard dependency on `payload`. +/** + * Permissive read-view over Payload's `Field` union for structural projection. + * + * `toFieldSchemas` walks every field type generically, probing properties + * (`name`, `fields`, `blocks`, `tabs`, `options`, …) as they appear rather than + * narrowing the 20-member discriminated union member by member. The public + * signature still takes the real `Field`/`Block` types; this view is only how + * the body reads each field. Nested `fields`/`tabs`/`blocks` keep their real + * Payload types so recursion stays type-checked; `options` is intentionally + * narrowed to the string-label shape the CLI projects. + */ +interface FieldView { + type: string; + name?: string; + required?: boolean; + localized?: boolean; + virtual?: boolean; + hasMany?: boolean; + relationTo?: string | string[]; + defaultValue?: unknown; + fields?: Field[]; + tabs?: Tab[]; + blocks?: Block[]; + blockReferences?: (Block | string)[]; + options?: (string | { label: string; value: string })[]; + editor?: unknown; +} + +// Alternative: import { flattenTopLevelFields } from 'payload/shared' with +// moveSubFieldsToTop: true — but that adds a runtime dependency on `payload`. export function toFieldSchemas( - fields: any[], - blocksBySlug: Record = {}, + fields: Field[], + blocksBySlug: Record = {}, ): FieldSchema[] { const result: FieldSchema[] = []; - for (const field of fields) { + for (const field of fields as unknown as FieldView[]) { // UI fields are admin-only React widgets — no data, irrelevant to agents. if (field.type === "ui") continue; // Tabs field: hoist unnamed tab fields, keep named tabs as nested if (field.type === "tabs" && Array.isArray(field.tabs)) { for (const tab of field.tabs) { - if (tab.name) { + if ("name" in tab && tab.name) { // Named tab — behaves like a group result.push({ name: tab.name, @@ -93,10 +122,10 @@ export function toFieldSchemas( } // Resolve inline blocks + blockReferences (slugs pointing to config.blocks) - const inlineBlocks: any[] = field.blocks && Array.isArray(field.blocks) ? field.blocks : []; - const refBlocks: any[] = Array.isArray(field.blockReferences) + const inlineBlocks: Block[] = Array.isArray(field.blocks) ? field.blocks : []; + const refBlocks: Block[] = Array.isArray(field.blockReferences) ? field.blockReferences - .map((blockRef: any) => { + .map((blockRef) => { const slug = typeof blockRef === "string" ? blockRef : blockRef.slug; return blocksBySlug[slug]; }) @@ -105,14 +134,14 @@ export function toFieldSchemas( const allBlocks = [...inlineBlocks, ...refBlocks]; if (allBlocks.length > 0) { - schema.blocks = allBlocks.map((block: any) => ({ + schema.blocks = allBlocks.map((block) => ({ slug: block.slug, fields: toFieldSchemas(block.fields || [], blocksBySlug), })); } if (field.options && Array.isArray(field.options)) { - schema.options = field.options.map((option: any) => + schema.options = field.options.map((option) => typeof option === "string" ? { label: option, value: option } : { label: option.label, value: option.value }, diff --git a/src/plugin/index.ts b/src/plugin/index.ts index e85a96c..cd5d969 100644 --- a/src/plugin/index.ts +++ b/src/plugin/index.ts @@ -13,11 +13,11 @@ * * This module is the Payload plugin wiring (endpoint capture, response * assembly). The reusable schema API lives in `./schemaApi.ts`, field/JSON-schema - * projection in `./fields.ts`, `./lexical.ts`, and `./jsonSchema.ts`. Types are - * kept inline to avoid a hard dependency on `payload`. + * projection in `./fields.ts`, `./lexical.ts`, and `./jsonSchema.ts`. All Payload + * types are imported type-only, so the package never pulls Payload in at runtime. */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Config, PayloadRequest } from "payload"; import { buildBlocksBySlug, buildLocalization, canRead, entityToSchema } from "./schemaApi.js"; import type { FieldSchema } from "./fields.js"; @@ -60,18 +60,22 @@ interface EndpointSchema { } /** Extract optional CLI metadata from an endpoint's `custom` property. */ -export function extractEndpointMeta(custom: any): Pick { +export function extractEndpointMeta( + custom: unknown, +): Pick { if (!custom || typeof custom !== "object") return {}; + const customRecord = custom as Record; const meta: Pick = {}; - if (typeof custom.description === "string") meta.description = custom.description; - if (custom.schema && typeof custom.schema === "object") { + if (typeof customRecord.description === "string") meta.description = customRecord.description; + if (customRecord.schema && typeof customRecord.schema === "object") { + const schemaRecord = customRecord.schema as Record; const schema: NonNullable = {}; - if (custom.schema.query && typeof custom.schema.query === "object") - schema.query = custom.schema.query; - if (custom.schema.body && typeof custom.schema.body === "object") - schema.body = custom.schema.body; - if (custom.schema.response && typeof custom.schema.response === "object") - schema.response = custom.schema.response; + if (schemaRecord.query && typeof schemaRecord.query === "object") + schema.query = schemaRecord.query as Record; + if (schemaRecord.body && typeof schemaRecord.body === "object") + schema.body = schemaRecord.body as Record; + if (schemaRecord.response && typeof schemaRecord.response === "object") + schema.response = schemaRecord.response as Record; if (Object.keys(schema).length > 0) meta.schema = schema; } return meta; @@ -80,7 +84,7 @@ export function extractEndpointMeta(custom: any): Pick boolean | Promise; + access?: (req: PayloadRequest) => boolean | Promise; } type ScopedEndpoint = EndpointSchema & { @@ -88,7 +92,7 @@ type ScopedEndpoint = EndpointSchema & { }; export function contentCliPlugin(options?: ContentCliPluginOptions) { - return (config: any): any => { + return (config: Config): Config => { // Capture custom endpoints from the raw user config before Payload // merges its built-in CRUD/auth routes into the runtime config. const customEndpoints: ScopedEndpoint[] = []; @@ -103,7 +107,7 @@ export function contentCliPlugin(options?: ContentCliPluginOptions) { } for (const collection of config.collections ?? []) { - for (const endpoint of collection.endpoints ?? []) { + for (const endpoint of collection.endpoints || []) { customEndpoints.push({ path: `/api/${collection.slug}${endpoint.path}`, method: endpoint.method, @@ -114,7 +118,7 @@ export function contentCliPlugin(options?: ContentCliPluginOptions) { } for (const global of config.globals ?? []) { - for (const endpoint of global.endpoints ?? []) { + for (const endpoint of global.endpoints || []) { customEndpoints.push({ path: `/api/globals/${global.slug}${endpoint.path}`, method: endpoint.method, @@ -131,7 +135,7 @@ export function contentCliPlugin(options?: ContentCliPluginOptions) { { path: "/content-cli/schema", method: "get", - handler: async (req: any) => { + handler: async (req: PayloadRequest) => { const allowed = options?.access ? await options.access(req) : !!req.user; if (!allowed) { return Response.json({ error: "Unauthorized" }, { status: 401 }); @@ -164,12 +168,12 @@ export function contentCliPlugin(options?: ContentCliPluginOptions) { } const endpoints: EndpointSchema[] = customEndpoints - .filter((ep) => { - if (!ep._scope) return true; - if (ep._scope.type === "collection") { - return readableCollections.has(ep._scope.slug); + .filter((endpoint) => { + if (!endpoint._scope) return true; + if (endpoint._scope.type === "collection") { + return readableCollections.has(endpoint._scope.slug); } - return readableGlobals.has(ep._scope.slug); + return readableGlobals.has(endpoint._scope.slug); }) .map(({ _scope, ...rest }) => rest); diff --git a/src/plugin/jsonSchema.ts b/src/plugin/jsonSchema.ts index 4827b1d..4b57a53 100644 --- a/src/plugin/jsonSchema.ts +++ b/src/plugin/jsonSchema.ts @@ -55,13 +55,13 @@ function fieldToJsonSchema(field: FieldSchema): JsonSchema | null { case "date": return { type: "string", format: "date-time" }; case "select": { - const enumValues = field.options?.map((o) => o.value) ?? []; + const enumValues = field.options?.map((option) => option.value) ?? []; const single: JsonSchema = enumValues.length > 0 ? { type: "string", enum: enumValues } : { type: "string" }; return field.hasMany ? { type: "array", items: single } : single; } case "radio": { - const enumValues = field.options?.map((o) => o.value) ?? []; + const enumValues = field.options?.map((option) => option.value) ?? []; return enumValues.length > 0 ? { type: "string", enum: enumValues } : { type: "string" }; } case "relationship": diff --git a/src/plugin/lexical.ts b/src/plugin/lexical.ts index a344dab..59bc817 100644 --- a/src/plugin/lexical.ts +++ b/src/plugin/lexical.ts @@ -3,16 +3,26 @@ * * Projects a `richText` field's lexical editor config into a * `LexicalFeatureSummary` describing the nodes an agent may emit. Detection is - * structural, so there is no hard dependency on `@payloadcms/richtext-lexical`. - * - * Types are kept inline to avoid a hard dependency on `payload`. + * structural — it probes the resolved editor config without naming any + * `@payloadcms/richtext-lexical` type — so the plugin never depends on the + * lexical package, only on `payload`'s own `Field`/`Block` types. */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { Block } from "payload"; import { toFieldSchemas } from "./fields.js"; import type { FieldSchema } from "./fields.js"; +/** Narrow an unknown to a plain (non-array) object so its keys can be read. */ +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +/** Read one property off an unknown value, returning `undefined` for non-objects. */ +function getProp(value: unknown, key: string): unknown { + return isRecord(value) ? value[key] : undefined; +} + /** * Per-`richText`-field summary of the lexical editor's enabled nodes. * @@ -146,12 +156,12 @@ const KNOWN_NODE_TYPES = new Set([ ]); /** Pull a feature's resolved props off whichever shape carries them. */ -function toProps(feature: any): Record { +function toProps(feature: unknown): Record { const candidate = - feature?.sanitizedServerFeatureProps ?? feature?.serverFeatureProps ?? feature?.props ?? {}; - return candidate && typeof candidate === "object" && !Array.isArray(candidate) - ? (candidate as Record) - : {}; + getProp(feature, "sanitizedServerFeatureProps") ?? + getProp(feature, "serverFeatureProps") ?? + getProp(feature, "props"); + return isRecord(candidate) ? candidate : {}; } /** @@ -161,17 +171,18 @@ function toProps(feature: any): Record { * Returns `[]` for features that register no nodes (text-format marks, layout, * editor chrome) or shapes without resolved nodes (e.g. unit-test fixtures). */ -function nodeTypesOf(feature: any): string[] { - const nodes = feature?.nodes; +function nodeTypesOf(feature: unknown): string[] { + const nodes = getProp(feature, "nodes"); if (!Array.isArray(nodes)) return []; const types: string[] = []; - for (const n of nodes) { - const entry = n?.node; + for (const node of nodes) { + const entry = getProp(node, "node"); if (!entry) continue; - const target = typeof entry === "object" && "with" in entry ? entry.replace : entry; - if (target && typeof target.getType === "function") { + const target = isRecord(entry) && "with" in entry ? getProp(entry, "replace") : entry; + const getType = getProp(target, "getType"); + if (typeof getType === "function") { try { - const type = target.getType(); + const type = (getType as () => unknown).call(target); if (typeof type === "string") types.push(type); } catch { // A node whose getType throws without an instance is not authorable @@ -198,10 +209,12 @@ function nodeTypesOf(feature: any): string[] { */ type NormalizedFeature = { key: string; props: Record; nodeTypes: string[] }; -function normalizeLexicalFeatures(editor: any): NormalizedFeature[] | undefined { - if (!editor || typeof editor !== "object") return undefined; +function normalizeLexicalFeatures(editor: unknown): NormalizedFeature[] | undefined { + if (!isRecord(editor)) return undefined; - const resolvedMap = editor.editorConfig?.resolvedFeatureMap ?? editor.resolvedFeatureMap; + const resolvedMap = + getProp(getProp(editor, "editorConfig"), "resolvedFeatureMap") ?? + getProp(editor, "resolvedFeatureMap"); if (resolvedMap instanceof Map) { const entries: NormalizedFeature[] = []; for (const [key, value] of resolvedMap.entries()) { @@ -211,10 +224,15 @@ function normalizeLexicalFeatures(editor: any): NormalizedFeature[] | undefined return entries; } - if (Array.isArray(editor.features)) { - return editor.features - .filter((f: any) => f && typeof f === "object" && typeof f.key === "string") - .map((f: any) => ({ key: f.key, props: toProps(f), nodeTypes: nodeTypesOf(f) })); + const features = editor.features; + if (Array.isArray(features)) { + const result: NormalizedFeature[] = []; + for (const feature of features) { + const key = getProp(feature, "key"); + if (typeof key !== "string") continue; + result.push({ key, props: toProps(feature), nodeTypes: nodeTypesOf(feature) }); + } + return result; } return undefined; @@ -232,8 +250,8 @@ function normalizeLexicalFeatures(editor: any): NormalizedFeature[] | undefined * map. Returns `undefined` when no recognizable features are present. */ export function extractLexicalSummary( - field: any, - blocksBySlug: Record, + field: { editor?: unknown }, + blocksBySlug: Record, ): LexicalFeatureSummary | undefined { const normalized = normalizeLexicalFeatures(field.editor); if (!normalized || normalized.length === 0) return undefined; @@ -288,7 +306,7 @@ export function extractLexicalSummary( const sizes = props.enabledHeadingSizes; blockNodes.heading = { sizes: - Array.isArray(sizes) && sizes.every((s) => typeof s === "string") + Array.isArray(sizes) && sizes.every((size) => typeof size === "string") ? (sizes as string[]) : ["h1", "h2", "h3", "h4", "h5", "h6"], }; @@ -332,16 +350,12 @@ export function extractLexicalSummary( // `collections: { [slug]: { fields } }` adds custom fields to the upload // node when it targets that collection — surface them keyed by slug. const collections = props.collections; - if (collections && typeof collections === "object" && !Array.isArray(collections)) { + if (isRecord(collections)) { const fieldsByCollection: Record = {}; - for (const [slug, cfg] of Object.entries(collections as Record)) { - if ( - cfg && - typeof cfg === "object" && - Array.isArray(cfg.fields) && - cfg.fields.length > 0 - ) { - fieldsByCollection[slug] = toFieldSchemas(cfg.fields, blocksBySlug); + for (const [slug, cfg] of Object.entries(collections)) { + const cfgFields = getProp(cfg, "fields"); + if (Array.isArray(cfgFields) && cfgFields.length > 0) { + fieldsByCollection[slug] = toFieldSchemas(cfgFields, blocksBySlug); } } if (Object.keys(fieldsByCollection).length > 0) uploadOpts.fields = fieldsByCollection; @@ -395,7 +409,7 @@ export function extractLexicalSummary( */ function extractBlockSlugs( props: Record, - blocksBySlug: Record, + blocksBySlug: Record, ): string[] | undefined { const raw = props.blocks; if (!Array.isArray(raw)) return undefined; @@ -403,9 +417,11 @@ function extractBlockSlugs( for (const entry of raw) { if (typeof entry === "string") { slugs.push(entry); - } else if (entry && typeof entry === "object" && typeof entry.slug === "string") { + } else if (isRecord(entry) && typeof entry.slug === "string") { slugs.push(entry.slug); - if (!(entry.slug in blocksBySlug)) blocksBySlug[entry.slug] = entry; + // Inline `Block` objects declared on the feature are registered so later + // `blockReferences` to them resolve; they're structurally Block configs. + if (!(entry.slug in blocksBySlug)) blocksBySlug[entry.slug] = entry as unknown as Block; } } return slugs.length > 0 ? slugs : undefined; @@ -418,6 +434,6 @@ function extractBlockSlugs( */ function toStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; - if (value.every((v) => typeof v === "string")) return value; + if (value.every((item) => typeof item === "string")) return value; return undefined; } diff --git a/src/plugin/schemaApi.ts b/src/plugin/schemaApi.ts index 4700882..17391a4 100644 --- a/src/plugin/schemaApi.ts +++ b/src/plugin/schemaApi.ts @@ -6,25 +6,35 @@ * building blocks for custom tools (e.g. a schema MCP server). They share the * endpoint's lenient, access-aware `canRead` rule. * - * Types are kept inline to avoid a hard dependency on `payload`. + * Uses type-only imports from `payload`, so the package never pulls Payload in + * at runtime. */ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { + Block, + Payload, + PayloadRequest, + SanitizedCollectionConfig, + SanitizedGlobalConfig, +} from "payload"; import { toFieldSchemas } from "./fields.js"; import type { FieldSchema } from "./fields.js"; import { entityToJsonSchema } from "./jsonSchema.js"; import type { JsonSchema } from "./jsonSchema.js"; +/** A collection or global config, the two access-controlled entity kinds. */ +type EntityConfig = SanitizedCollectionConfig | SanitizedGlobalConfig; + // Mirrors Payload's read-access evaluation (see auth/getEntityPermissions.ts): // a function returning true OR a Where clause counts as "has read access"; // falsy counts as "denied". With no `access.read` defined, Payload's default // is `isLoggedIn`. -export async function canRead(entity: any, req: any): Promise { - const fn = entity?.access?.read; - if (typeof fn !== "function") return !!req.user; +export async function canRead(entity: EntityConfig, req: PayloadRequest): Promise { + const readAccess = entity?.access?.read; + if (typeof readAccess !== "function") return !!req.user; try { - const result = await fn({ req }); + const result = await readAccess({ req }); return !!result; } catch { return false; @@ -32,8 +42,8 @@ export async function canRead(entity: any, req: any): Promise { } /** Build the slug → top-level block lookup used to resolve `blockReferences`. */ -export function buildBlocksBySlug(payload: any): Record { - const blocksBySlug: Record = {}; +export function buildBlocksBySlug(payload: Payload): Record { + const blocksBySlug: Record = {}; for (const block of payload.config.blocks ?? []) { blocksBySlug[block.slug] = block; } @@ -42,12 +52,12 @@ export function buildBlocksBySlug(payload: any): Record { /** Project Payload's localization config to its CLI/JSON shape, or null. */ export function buildLocalization( - payload: any, + payload: Payload, ): { locales: string[]; defaultLocale: string } | null { const localization = payload.config.localization; if (!localization) return null; return { - locales: (localization.locales as any[]).map((locale: any) => + locales: localization.locales.map((locale) => typeof locale === "string" ? locale : locale.code, ), defaultLocale: localization.defaultLocale, @@ -56,8 +66,8 @@ export function buildLocalization( /** Assemble the per-entity schema shape from an already-resolved entity config. */ export function entityToSchema( - entity: any, - blocksBySlug: Record, + entity: EntityConfig, + blocksBySlug: Record, ): { slug: string; fields: FieldSchema[]; jsonSchema: JsonSchema } { const fields = toFieldSchemas(entity.fields, blocksBySlug); return { slug: entity.slug, fields, jsonSchema: entityToJsonSchema(entity.slug, fields) }; @@ -81,13 +91,14 @@ export async function getEntitySchema({ type, slug, }: { - req: any; + req: PayloadRequest; type: "collection" | "global"; slug: string; }): Promise<{ slug: string; fields: FieldSchema[]; jsonSchema: JsonSchema }> { const payload = req.payload; - const list = type === "collection" ? payload.config.collections : payload.config.globals; - const entity = (list ?? []).find((e: any) => e.slug === slug); + const list: EntityConfig[] = + type === "collection" ? payload.config.collections : payload.config.globals; + const entity = (list ?? []).find((candidate) => candidate.slug === slug); if (!entity) { throw new Error(`No ${type} with slug "${slug}"`); } @@ -121,7 +132,7 @@ export async function getBlockSchema({ req, slugs, }: { - req: any; + req: PayloadRequest; slugs: string[]; }): Promise<{ slug: string; fields: FieldSchema[] }[]> { const blocksBySlug = buildBlocksBySlug(req.payload); @@ -157,7 +168,7 @@ export async function getBlockSchema({ * prefix) or "internal collection" filtering in the caller; this helper mirrors * the endpoint and filters by access alone. */ -export async function listReadableEntities({ req }: { req: any }): Promise<{ +export async function listReadableEntities({ req }: { req: PayloadRequest }): Promise<{ collections: string[]; globals: string[]; localization: { locales: string[]; defaultLocale: string } | null; diff --git a/src/profiles.ts b/src/profiles.ts index f2431ed..b57d99d 100644 --- a/src/profiles.ts +++ b/src/profiles.ts @@ -15,7 +15,7 @@ const profileSchema = z authCollection: z.string().min(1).optional(), outputDir: z.string().min(1).optional(), }) - .refine((p) => !(p.apiKey && p.credentialCommand), { + .refine((profile) => !(profile.apiKey && profile.credentialCommand), { message: "Profile cannot set both apiKey and credentialCommand", path: ["credentialCommand"], }); diff --git a/src/pull.ts b/src/pull.ts index 27c8ecc..db5e07a 100644 --- a/src/pull.ts +++ b/src/pull.ts @@ -36,7 +36,7 @@ function stripVirtualFields(doc: Record, fields: FieldSchema[]) if (Array.isArray(doc[field.name])) { for (const item of doc[field.name] as Record[]) { const blockType = item.blockType as string | undefined; - const blockDef = field.blocks.find((b) => b.slug === blockType); + const blockDef = field.blocks.find((block) => block.slug === blockType); if (blockDef) { stripVirtualFields(item, blockDef.fields); } @@ -286,8 +286,8 @@ export async function pull(config: Config, options: PullOptions = {}): Promise `collections/${s}/`), - ...targetGlobals.map((s) => `globals/${s}/`), + ...targetCollections.map((slug) => `collections/${slug}/`), + ...targetGlobals.map((slug) => `globals/${slug}/`), ]; for (const key of Object.keys(manifest.documents)) { if (pulledPrefixes.some((prefix) => key.startsWith(prefix))) { diff --git a/src/push.ts b/src/push.ts index 1cfd2dc..b84a0e0 100644 --- a/src/push.ts +++ b/src/push.ts @@ -116,7 +116,7 @@ export async function push(config: Config, options: PushOptions = {}): Promise path.resolve(f)); + filePaths = options.files.map((file) => path.resolve(file)); } else { // Default: push only modified + added files (via status) const localStatus = await status(config); @@ -131,8 +131,8 @@ export async function push(config: Config, options: PushOptions = {}): Promise parseContentPath(f, outputDir)) - .filter((e): e is ContentEntry => e !== null); + .map((file) => parseContentPath(file, outputDir)) + .filter((entry): entry is ContentEntry => entry !== null); if (entries.length === 0) { console.log("No changes to push.");