diff --git a/.eslintignore b/.eslintignore index baca58ff..7845d18c 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ CHANGELOG.md # .gitignore copy # custom +*.tgz dist examples/**/package-lock.json diff --git a/.gitignore b/.gitignore index 3efa9b36..4a9e6b52 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # custom +*.tgz dist examples/**/package-lock.json diff --git a/.prettierignore b/.prettierignore index d378e5d8..a841146e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,6 +4,7 @@ CHANGELOG.md # .gitignore copy # custom +*.tgz dist examples/**/package-lock.json diff --git a/CHANGELOG.md b/CHANGELOG.md index a98d3daa..68471885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,49 +2,19 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. -## [7.10.0-alpha.0](https://github.com/prismicio/prismic-client/compare/v7.9.0...v7.10.0-alpha.0) (2024-09-20) +### [7.10.1](https://github.com/prismicio/prismic-client/compare/v7.10.0...v7.10.1) (2024-09-23) -## [7.9.0-alpha.3](https://github.com/prismicio/prismic-client/compare/v7.8.1...v7.9.0-alpha.3) (2024-09-10) +### Bug Fixes -### Chore - -* **release:** 7.9.0-alpha.3 ([0d3fb98](https://github.com/prismicio/prismic-client/commit/0d3fb98743b79d3fc2a41dcb2fc6edfc67857478)) - -## [7.9.0-alpha.2](https://github.com/prismicio/prismic-client/compare/v7.9.0-alpha.1...v7.9.0-alpha.2) (2024-08-27) - - -### Chore - -* **release:** 7.9.0-alpha.2 ([000f146](https://github.com/prismicio/prismic-client/commit/000f146d25b84b2937c3accaffa54ab936a78de8)) -* revert previous changes ([c53de86](https://github.com/prismicio/prismic-client/commit/c53de86a7ed749f0f9d8999b9e58564d9a8efe35)) - -## [7.9.0-alpha.1](https://github.com/prismicio/prismic-client/compare/v7.9.0-alpha.0...v7.9.0-alpha.1) (2024-08-27) - - -### Features - -* use KeyTextField for link text value definition ([1a4c51a](https://github.com/prismicio/prismic-client/commit/1a4c51a389ed4d06cf69c3edcb215559ba91fe99)) - - -### Chore - -* **release:** 7.9.0-alpha.1 ([d940fcd](https://github.com/prismicio/prismic-client/commit/d940fcdb023de34765006607f7f34481de00a6ef)) -* remove changelog for alpha ([50fc797](https://github.com/prismicio/prismic-client/commit/50fc797372726c0abc575b9f1b0a56d5db939bb1)) +* ensure correct content type is attached to asset's blob ([80fe887](https://github.com/prismicio/prismic-client/commit/80fe887235e2eda594876f08db54cdb544e20b9f)) -## [7.9.0-alpha.0](https://github.com/prismicio/prismic-client/compare/v7.8.0...v7.9.0-alpha.0) (2024-08-14) +## [7.10.0](https://github.com/prismicio/prismic-client/compare/v7.9.0...v7.10.0) (2024-09-23) ### Features -* add text property to link models ([40d0e0b](https://github.com/prismicio/prismic-client/commit/40d0e0be3ce8038cd36e8cdf3350ffa9893ada98)) -* add text to link value models ([8459d75](https://github.com/prismicio/prismic-client/commit/8459d75608c1af8319adafd2b3bf73555569a434)) - - -### Chore - -* **release:** 7.9.0-alpha.0 ([f2021c0](https://github.com/prismicio/prismic-client/commit/f2021c053b9aac1c17b7bbd1845c91eab7b0d176)) -* update package version ([c06b383](https://github.com/prismicio/prismic-client/commit/c06b383ff41f01e253641c7852a4e9701725bdba)) +* add `createMigration` and `createWriteClient` ([#350](https://github.com/prismicio/prismic-client/issues/350)) ([7dc2950](https://github.com/prismicio/prismic-client/commit/7dc2950e25e7f8db706522175974b8aee36d170e)) ## [7.9.0](https://github.com/prismicio/prismic-client/compare/v7.8.1...v7.9.0) (2024-09-17) @@ -65,13 +35,6 @@ All notable changes to this project will be documented in this file. See [standa * add missing `is_master` property on repository type ([0289698](https://github.com/prismicio/prismic-client/commit/0289698cc9ca7ed5b264f71f2a9f8d39e3710058)) -## [7.9.0-alpha.2](https://github.com/prismicio/prismic-client/compare/v7.9.0-alpha.1...v7.9.0-alpha.2) (2024-08-27) - - -### Chore - -* revert previous changes ([c53de86](https://github.com/prismicio/prismic-client/commit/c53de86a7ed749f0f9d8999b9e58564d9a8efe35)) - ## [7.8.0](https://github.com/prismicio/prismic-client/compare/v7.7.4...v7.8.0) (2024-08-01) diff --git a/examples/migrate/README.md b/examples/migrate/README.md new file mode 100644 index 00000000..7d89e546 --- /dev/null +++ b/examples/migrate/README.md @@ -0,0 +1,26 @@ +# Migrate + +This example shows a migration script leveraging `@prismicio/client`'s write client +and the migration helper to migrate existing content to Prismic. `migrate.ts` is the +TypeScript exact equivalent of `migrate.mjs`. + +Learn more about migrating to Prismic on the [migration documentation](https://prismic.io/docs/migration). + +## How to run the example + +> Scripts in this example uses an hypothetical WordPress client, therefore, they are not runnable as-is. + +```sh +# Clone the repository to your computer +git clone https://github.com/prismicio/prismic-client.git +cd prismic-client/examples/migrate + +# Install the dependencies +npm install + +# Run the example (TypeScript) +npx tsx migrate.ts + +# Run the example (JavaScript) +node migrate.mjs +``` diff --git a/examples/migrate/migrate.mjs b/examples/migrate/migrate.mjs new file mode 100644 index 00000000..99d199db --- /dev/null +++ b/examples/migrate/migrate.mjs @@ -0,0 +1,101 @@ +import * as prismic from "@prismicio/client" +import { htmlAsRichText } from "@prismicio/migrate" +import "dotenv/config" +// An hypothetical WordPress client +import { createWordPressClient } from "wordpress" + +import { repositoryName } from "./slicemachine.config.json" + +// Prismic setup +const writeClient = prismic.createWriteClient(repositoryName, { + writeToken: process.env.PRISMIC_WRITE_TOKEN, +}) + +const migration = prismic.createMigration() + +// Custom migration script logic + +const convertWPDocument = (wpDocument) => { + switch (wpDocument.type) { + case "page": + return convertWPPage(wpDocument) + case "settings": + return convertWPSettings(wpDocument) + } + + throw new Error(`Unsupported document type: ${wpDocument.type}`) +} + +const convertWPPage = (wpPage) => { + return migration.createDocument( + { + type: "page", + lang: wpPage.lang, + uid: wpPage.slug, + tags: ["wordpress"], + data: { + meta_title: wpPage.meta_title, + meta_description: wpPage.meta_description, + meta_image: migration.createAsset( + wpPage.meta_image.url, + wpPage.meta_image.name, + ), + title: wpHTMLAsRichText(wpPage.title), + body: wpHTMLAsRichText(wpPage.content), + }, + }, + wpPage.name, + { + masterLanguageDocument: () => + migration.getByUID( + wpPage.masterLanguageDocument.type, + wpPage.masterLanguageDocument.uid, + ), + }, + ) +} + +const convertWPSettings = (wpSettings) => { + return migration.createDocument( + { + type: "settings", + lang: wpSettings.lang, + tags: ["wordpress"], + data: { + title: wpHTMLAsRichText(wpSettings.name), + }, + }, + "Settings", + ) +} + +const wpHTMLAsRichText = (html) => { + return htmlAsRichText(html, { + serializer: { + img: ({ node }) => { + const src = node.properties.src + const filename = src.split("/").pop() + const alt = node.properties.alt + + return { + type: "image", + id: migration.createAsset(src, filename, { alt }), + } + }, + }, + }).result +} + +// Fetching and converting WordPress documents +const wpClient = createWordPressClient("https://example.com/wp-json") + +const wpDocuments = await wpClient.dangerouslyGetAllDocuments() + +for (const wpDocument of wpDocuments) { + convertWPDocument(wpDocument) +} + +// Execute the prepared migration at the very end of the script +await writeClient.migrate(migration, { + reporter: (event) => console.info(event), +}) diff --git a/examples/migrate/migrate.ts b/examples/migrate/migrate.ts new file mode 100644 index 00000000..abdc7603 --- /dev/null +++ b/examples/migrate/migrate.ts @@ -0,0 +1,102 @@ +import * as prismic from "@prismicio/client" +import { htmlAsRichText } from "@prismicio/migrate" +import "dotenv/config" +// An hypothetical WordPress client +// @ts-expect-error - This is an hypothetical WordPress client +import { type WPDocument, createWordPressClient } from "wordpress" + +import { repositoryName } from "./slicemachine.config.json" + +// Prismic setup +const writeClient = prismic.createWriteClient(repositoryName, { + writeToken: process.env.PRISMIC_WRITE_TOKEN!, +}) + +const migration = prismic.createMigration() + +// Custom migration script logic + +const convertWPDocument = (wpDocument: WPDocument) => { + switch (wpDocument.type) { + case "page": + return convertWPPage(wpDocument) + case "settings": + return convertWPSettings(wpDocument) + } + + throw new Error(`Unsupported document type: ${wpDocument.type}`) +} + +const convertWPPage = (wpPage: WPDocument) => { + return migration.createDocument( + { + type: "page", + lang: wpPage.lang, + uid: wpPage.slug, + tags: ["wordpress"], + data: { + meta_title: wpPage.meta_title, + meta_description: wpPage.meta_description, + meta_image: migration.createAsset( + wpPage.meta_image.url, + wpPage.meta_image.name, + ), + title: wpHTMLAsRichText(wpPage.title), + body: wpHTMLAsRichText(wpPage.content), + }, + }, + wpPage.name, + { + masterLanguageDocument: () => + migration.getByUID( + wpPage.masterLanguageDocument.type, + wpPage.masterLanguageDocument.uid, + ), + }, + ) +} + +const convertWPSettings = (wpSettings: WPDocument) => { + return migration.createDocument( + { + type: "settings", + lang: wpSettings.lang, + tags: ["wordpress"], + data: { + title: wpHTMLAsRichText(wpSettings.name), + }, + }, + "Settings", + ) +} + +const wpHTMLAsRichText = (html: string) => { + return htmlAsRichText(html, { + serializer: { + img: ({ node }) => { + const src = node.properties.src as string + const filename = src.split("/").pop()! + const alt = node.properties.alt as string + + return { + type: "image", + id: migration.createAsset(src, filename, { alt }), + } + }, + }, + }).result +} + +// Fetching and converting WordPress documents +const wpClient = createWordPressClient("https://example.com/wp-json") + +const wpDocuments = await wpClient.dangerouslyGetAllDocuments() + +for (const wpDocument of wpDocuments) { + convertWPDocument(wpDocument) +} + +// Execute the prepared migration at the very end of the script +await writeClient.migrate(migration, { + reporter: (event) => console.info(event), +}) diff --git a/examples/migrate/package.json b/examples/migrate/package.json new file mode 100644 index 00000000..e46fa25b --- /dev/null +++ b/examples/migrate/package.json @@ -0,0 +1,11 @@ +{ + "type": "module", + "dependencies": { + "@prismicio/client": "latest", + "@prismicio/migrate": "latest" + }, + "devDependencies": { + "tsx": "^4.19.1", + "typescript": "^5.6.2" + } +} diff --git a/examples/migrate/slicemachine.config.json b/examples/migrate/slicemachine.config.json new file mode 100644 index 00000000..4e437b89 --- /dev/null +++ b/examples/migrate/slicemachine.config.json @@ -0,0 +1,6 @@ +{ + "libraries": ["./slices"], + "adapter": "@slicemachine/adapter-next", + "repositoryName": "qwerty", + "localSliceSimulatorURL": "http://localhost:3000/slice-simulator" +} diff --git a/examples/migrate/tsconfig.json b/examples/migrate/tsconfig.json new file mode 100644 index 00000000..7569e1d0 --- /dev/null +++ b/examples/migrate/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "esnext", + "lib": ["esnext"], + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "strict": true, + "strictNullChecks": true, + "esModuleInterop": true, + "skipDefaultLibCheck": true, + "skipLibCheck": true + } +} diff --git a/messages/avoid-write-client-in-browser.md b/messages/avoid-write-client-in-browser.md new file mode 100644 index 00000000..a45729ae --- /dev/null +++ b/messages/avoid-write-client-in-browser.md @@ -0,0 +1,23 @@ +# Avoid write client in browser + +`@prismicio/client`'s write client uses credentials to authenticate write queries to a Prismic repository. + +The repository write token and Migration API key must be provided when creating a `@prismicio/client` write client like the following: + +```typescript +import * as prismic from "@prismicio/client"; + +const writeClient = prismic.createWriteClient("example-prismic-repo", { + writeToken: "xxx" +}) +``` + +If the write client is exposed to the browser, so are its tokens. Malicious actors will have write access to your repository. + +Use the non-write client when write actions are not needed. The non-write client only has read access to the repository and can safely be used in the browser. Be aware the client's access token, if used, will be exposed in the browser. + +```typescript +import * as prismic from "@prismicio/client"; + +const client = prismic.createClient("example-prismic-repo") +``` diff --git a/messages/endpoint-must-use-cdn.md b/messages/endpoint-must-use-cdn.md index 1d78848d..405e385b 100644 --- a/messages/endpoint-must-use-cdn.md +++ b/messages/endpoint-must-use-cdn.md @@ -1,20 +1,16 @@ # `endpoint` must use CDN -`@prismicio/client` uses either a Prismic repository name or a Prismic Rest API v2 repository endpoint to query content from Prismic. +`@prismicio/client` uses either a Prismic repository name or a repository-specific Document API endpoint to query content from Prismic. -The repository name or repository endpoint must be provided when creating a `@prismicio/client` like the following: +The repository name must be provided when creating a `@prismicio/client` like the following: ```typescript import * as prismic from "@prismicio/client"; -// Using the repository name const client = prismic.createClient("example-prismic-repo") - -// Using the repository endpoint -const client = prismic.createClient("https://example-prismic-repo.cdn.prismic.io/api/v2") ``` -When creating a `@prismicio/client` with a repository endpoint, the endpoint's subdomain must feature the `.cdn` suffix. +When creating a `@prismicio/client` with a repository endpoint (not recommended), the endpoint's subdomain must feature the `.cdn` suffix. ```typescript import * as prismic from "@prismicio/client"; diff --git a/messages/prefer-repository-name.md b/messages/prefer-repository-name.md new file mode 100644 index 00000000..3b6ca43a --- /dev/null +++ b/messages/prefer-repository-name.md @@ -0,0 +1,29 @@ +# Prefer repository name + +`@prismicio/client` uses either a Prismic repository name or a repository-specific Document API endpoint to query content from Prismic. + +The repository name must be provided when creating a `@prismicio/client` like the following: + +```typescript +import * as prismic from "@prismicio/client"; + +const client = prismic.createClient("example-prismic-repo") +``` + +When proxying a Prismic API v2 repository endpoint (not recommended), the `documentAPIEndpoint` option can be used to specify that endpoint. + +```typescript +import * as prismic from "@prismicio/client" + +// ✅ Correct +const client = prismic.createClient("my-repo-name", { + documentAPIEndpoint: "https://example.com/my-prismic-proxy" +}) + +// ❌ Incorrect: repository name can't be inferred from a proxied endpoint +const client = prismic.createClient("https://example.com/my-prismic-proxy", { + documentAPIEndpoint: "https://example.com/my-prismic-proxy" +}) +``` + +Proxying a Prismic API v2 repository endpoint can have unexpected side-effects and cause performance issues when querying Prismic. diff --git a/package-lock.json b/package-lock.json index f39b05d2..041bce6e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,18 +1,18 @@ { "name": "@prismicio/client", - "version": "7.10.0-alpha.0", + "version": "7.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@prismicio/client", - "version": "7.10.0-alpha.0", + "version": "7.10.1", "license": "Apache-2.0", "dependencies": { "imgix-url-builder": "^0.0.5" }, "devDependencies": { - "@prismicio/mock": "^0.3.7", + "@prismicio/mock": "^0.3.9", "@prismicio/types-internal": "^2.6.0", "@size-limit/preset-small-lib": "^11.1.4", "@trivago/prettier-plugin-sort-imports": "^4.3.0", @@ -1212,9 +1212,9 @@ } }, "node_modules/@prismicio/mock": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/@prismicio/mock/-/mock-0.3.7.tgz", - "integrity": "sha512-aXNSWMVTSanVSVxgIw0q+1YHj33Yv9GjVX9MuWBIQddf0dmDSMSiLEmDG1LSCgWq2cpukLMf/4eSo3P4dwG/kQ==", + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@prismicio/mock/-/mock-0.3.9.tgz", + "integrity": "sha512-gZPuacdWJsNZmI+EQx2NB5c7cqUUK+3vht/Uu9OjbRILOmzoZZ5crArhudoLO1POf+x1DNx3PA5BGO2UtyizoA==", "dev": true, "license": "Apache-2.0", "dependencies": { diff --git a/package.json b/package.json index 3e155217..15a47593 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@prismicio/client", - "version": "7.10.0-alpha.0", + "version": "7.10.1", "description": "The official JavaScript + TypeScript client library for Prismic", "keywords": [ "typescript", @@ -82,7 +82,7 @@ "imgix-url-builder": "^0.0.5" }, "devDependencies": { - "@prismicio/mock": "^0.3.7", + "@prismicio/mock": "^0.3.9", "@prismicio/types-internal": "^2.6.0", "@size-limit/preset-small-lib": "^11.1.4", "@trivago/prettier-plugin-sort-imports": "^4.3.0", diff --git a/src/BaseClient.ts b/src/BaseClient.ts new file mode 100644 index 00000000..32a736a3 --- /dev/null +++ b/src/BaseClient.ts @@ -0,0 +1,330 @@ +import { type LimitFunction, pLimit } from "./lib/pLimit" + +import { PrismicError } from "./errors/PrismicError" + +/** + * The default delay used with APIs not providing rate limit headers. + * + * @internal + */ +export const UNKNOWN_RATE_LIMIT_DELAY = 1500 + +/** + * A universal API to make network requests. A subset of the `fetch()` API. + * + * {@link https://developer.mozilla.org/en-US/docs/Web/API/fetch} + */ +export type FetchLike = ( + input: string, + init?: RequestInitLike, +) => Promise + +/** + * An object that allows you to abort a `fetch()` request if needed via an + * `AbortController` object + * + * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal} + */ +// `any` is used often here to ensure this type is universally valid among +// different AbortSignal implementations. The types of each property are not +// important to validate since it is blindly passed to a given `fetch()` +// function. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AbortSignalLike = any + +/** + * A subset of RequestInit properties to configure a `fetch()` request. + */ +// Only options relevant to the client are included. Extending from the full +// RequestInit would cause issues, such as accepting Header objects. +// +// An interface is used to allow other libraries to augment the type with +// environment-specific types. +export interface RequestInitLike extends Pick { + /** + * The HTTP method to use for the request. + */ + method?: string + + /** + * The request body to send with the request. + */ + // We want to keep the body type as compatible as possible, so + // we only declare the type we need and accept anything else. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body?: any | FormData | string + + /** + * An object literal to set the `fetch()` request's headers. + */ + headers?: Record + + /** + * An AbortSignal to set the `fetch()` request's signal. + * + * See: + * [https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) + */ + // NOTE: `AbortSignalLike` is `any`! It is left as `AbortSignalLike` + // for backwards compatibility (the type is exported) and to signal to + // other readers that this should be an AbortSignal-like object. + signal?: AbortSignalLike +} + +/** + * The minimum required properties from Response. + */ +export interface ResponseLike { + ok: boolean + status: number + headers: HeadersLike + // eslint-disable-next-line @typescript-eslint/no-explicit-any + json(): Promise + text(): Promise + blob(): Promise +} + +/** + * The minimum required properties from Headers. + */ +export interface HeadersLike { + get(name: string): string | null +} + +/** + * The minimum required properties to treat as an HTTP Request for automatic + * Prismic preview support. + */ +export type HttpRequestLike = + | /** + * Web API Request + * + * @see http://developer.mozilla.org/en-US/docs/Web/API/Request + */ + { + headers?: { + get(name: string): string | null + } + url?: string + } + + /** + * Express-style Request + */ + | { + headers?: { + cookie?: string + } + query?: Record + } + +/** + * Configuration for clients that determine how APIs are queried. + */ +export type BaseClientConfig = { + /** + * The function used to make network requests to the Prismic REST API. In + * environments where a global `fetch` function does not exist, such as + * Node.js, this function must be provided. + */ + fetch?: FetchLike + + /** + * Options provided to the client's `fetch()` on all network requests. These + * options will be merged with internally required options. They can also be + * overriden on a per-query basis using the query's `fetchOptions` parameter. + */ + fetchOptions?: RequestInitLike +} + +/** + * Parameters for any client method that use `fetch()`. + */ +export type FetchParams = { + /** + * Options provided to the client's `fetch()` on all network requests. These + * options will be merged with internally required options. They can also be + * overriden on a per-query basis using the query's `fetchOptions` parameter. + */ + fetchOptions?: RequestInitLike + + /** + * An `AbortSignal` provided by an `AbortController`. This allows the network + * request to be cancelled if necessary. + * + * @deprecated Move the `signal` parameter into `fetchOptions.signal`: + * + * @see \ + */ + signal?: AbortSignalLike +} + +/** + * The result of a `fetch()` job. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type FetchJobResult = { + status: number + headers: HeadersLike + json: TJSON + text?: string +} + +export abstract class BaseClient { + /** + * The function used to make network requests to the Prismic REST API. In + * environments where a global `fetch` function does not exist, such as + * Node.js, this function must be provided. + */ + fetchFn: FetchLike + + fetchOptions?: RequestInitLike + + /** + * Active queued `fetch()` jobs keyed by URL and AbortSignal (if it exists). + */ + private queuedFetchJobs: Record = {} + + /** + * Active deduped `fetch()` jobs keyed by URL and AbortSignal (if it exists). + */ + private dedupedFetchJobs: Record< + string, + Map> + > = {} + + constructor(options: BaseClientConfig) { + this.fetchOptions = options.fetchOptions + + if (typeof options.fetch === "function") { + this.fetchFn = options.fetch + } else if (typeof globalThis.fetch === "function") { + this.fetchFn = globalThis.fetch as FetchLike + } else { + throw new PrismicError( + "A valid fetch implementation was not provided. In environments where fetch is not available (including Node.js), a fetch implementation must be provided via a polyfill or the `fetch` option.", + undefined, + undefined, + ) + } + + // If the global fetch function is used, we must bind it to the global scope. + if (this.fetchFn === globalThis.fetch) { + this.fetchFn = this.fetchFn.bind(globalThis) + } + } + + protected async fetch( + url: string, + params: FetchParams = {}, + ): Promise { + const requestInit: RequestInitLike = { + ...this.fetchOptions, + ...params.fetchOptions, + headers: { + ...this.fetchOptions?.headers, + ...params.fetchOptions?.headers, + }, + signal: + params.fetchOptions?.signal || + params.signal || + this.fetchOptions?.signal, + } + + // Request with a `body` are throttled, others are deduped. + if (params.fetchOptions?.body) { + return this.queueFetch(url, requestInit) + } else { + return this.dedupeFetch(url, requestInit) + } + } + + private queueFetch( + url: string, + requestInit: RequestInitLike = {}, + ): Promise { + // Rate limiting is done per hostname. + const hostname = new URL(url).hostname + + if (!this.queuedFetchJobs[hostname]) { + this.queuedFetchJobs[hostname] = pLimit({ + interval: UNKNOWN_RATE_LIMIT_DELAY, + }) + } + + return this.queuedFetchJobs[hostname](() => + this.createFetchJob(url, requestInit), + ) + } + + private dedupeFetch( + url: string, + requestInit: RequestInitLike = {}, + ): Promise { + let job: Promise + + // `fetchJobs` is keyed twice: first by the URL and again by is + // signal, if one exists. + // + // Using two keys allows us to reuse fetch requests for + // equivalent URLs, but eject when we detect unique signals. + if ( + this.dedupedFetchJobs[url] && + this.dedupedFetchJobs[url].has(requestInit.signal) + ) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + job = this.dedupedFetchJobs[url].get(requestInit.signal)! + } else { + this.dedupedFetchJobs[url] = this.dedupedFetchJobs[url] || new Map() + + job = this.createFetchJob(url, requestInit).finally(() => { + this.dedupedFetchJobs[url]?.delete(requestInit.signal) + + if (this.dedupedFetchJobs[url]?.size === 0) { + delete this.dedupedFetchJobs[url] + } + }) + + this.dedupedFetchJobs[url].set(requestInit.signal, job) + } + + return job + } + + private createFetchJob( + url: string, + requestInit: RequestInitLike = {}, + ): Promise { + return this.fetchFn(url, requestInit).then(async (res) => { + // We can assume Prismic REST API responses + // will have a `application/json` + // Content Type. If not, this will + // throw, signaling an invalid + // response. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let json: any = undefined + let text: string | undefined = undefined + if (res.ok) { + try { + json = await res.json() + } catch { + // noop + } + } else { + try { + text = await res.text() + json = JSON.parse(text) + } catch { + // noop + } + } + + return { + status: res.status, + headers: res.headers, + json, + text, + } + }) + } +} diff --git a/src/Client.ts b/src/Client.ts new file mode 100644 index 00000000..22fcd565 --- /dev/null +++ b/src/Client.ts @@ -0,0 +1,1787 @@ +import { appendFilters } from "./lib/appendFilters" +import { castThunk } from "./lib/castThunk" +import { devMsg } from "./lib/devMsg" +import { everyTagFilter } from "./lib/everyTagFilter" +import { findMasterRef } from "./lib/findMasterRef" +import { findRefByID } from "./lib/findRefByID" +import { findRefByLabel } from "./lib/findRefByLabel" +import { getPreviewCookie } from "./lib/getPreviewCookie" +import { minifyGraphQLQuery } from "./lib/minifyGraphQLQuery" +import { someTagsFilter } from "./lib/someTagsFilter" +import { typeFilter } from "./lib/typeFilter" + +import type { Query } from "./types/api/query" +import type { Ref } from "./types/api/ref" +import type { Form, Repository } from "./types/api/repository" +import type { PrismicDocument } from "./types/value/document" + +import { ForbiddenError } from "./errors/ForbiddenError" +import { NotFoundError } from "./errors/NotFoundError" +import { ParsingError } from "./errors/ParsingError" +import { PreviewTokenExpiredError } from "./errors/PreviewTokenExpired" +import { PrismicError } from "./errors/PrismicError" +import { RefExpiredError } from "./errors/RefExpiredError" +import { RefNotFoundError } from "./errors/RefNotFoundError" +import { RepositoryNotFoundError } from "./errors/RepositoryNotFoundError" + +import type { LinkResolverFunction } from "./helpers/asLink" +import { asLink } from "./helpers/asLink" + +import { + type AbortSignalLike, + BaseClient, + type BaseClientConfig, + type FetchParams, + type HttpRequestLike, +} from "./BaseClient" +import type { BuildQueryURLArgs } from "./buildQueryURL" +import { buildQueryURL } from "./buildQueryURL" +import { filter } from "./filter" +import { getRepositoryEndpoint } from "./getRepositoryEndpoint" +import { getRepositoryName } from "./getRepositoryName" +import { isRepositoryEndpoint } from "./isRepositoryEndpoint" + +/** + * The largest page size allowed by the Prismic REST API V2. This value is used + * to minimize the number of requests required to query content. + */ +const MAX_PAGE_SIZE = 100 + +/** + * The number of milliseconds in which repository metadata is considered valid. + * A ref can be invalidated quickly depending on how frequently content is + * updated in the Prismic repository. As such, repository's metadata can only be + * considered valid for a short amount of time. + */ +export const REPOSITORY_CACHE_TTL = 5000 + +/** + * The number of milliseconds in which a multi-page `getAll` (e.g. `getAll`, + * `getAllByType`, `getAllByTag`) will wait between individual page requests. + * + * This is done to ensure API performance is sustainable and reduces the chance + * of a failed API request due to overloading. + */ +export const GET_ALL_QUERY_DELAY = 500 + +/** + * The default number of milliseconds to wait before retrying a rate-limited + * `fetch()` request (429 response code). The default value is only used if the + * response does not include a `retry-after` header. + * + * The API allows up to 200 requests per second. + */ +const DEFUALT_RETRY_AFTER_MS = 1000 + +/** + * Extracts one or more Prismic document types that match a given Prismic + * document type. If no matches are found, no extraction is performed and the + * union of all provided Prismic document types are returned. + * + * @typeParam TDocuments - Prismic document types from which to extract. + * @typeParam TDocumentType - Type(s) to match `TDocuments` against. + */ +type ExtractDocumentType< + TDocuments extends PrismicDocument, + TDocumentType extends TDocuments["type"], +> = + Extract extends never + ? TDocuments + : Extract + +/** + * Modes for client ref management. + */ +enum RefStateMode { + /** + * Use the repository's master ref. + */ + Master = "Master", + + /** + * Use a given Release identified by its ID. + */ + ReleaseID = "ReleaseID", + + /** + * Use a given Release identified by its label. + */ + ReleaseLabel = "ReleaseLabel", + + /** + * Use a given ref. + */ + Manual = "Manual", +} + +/** + * An object containing stateful information about a client's ref strategy. + */ +type RefState = { + /** + * Determines if automatic preview support is enabled. + */ + autoPreviewsEnabled: boolean + + /** + * An optional HTTP server request object used during previews if automatic + * previews are enabled. + */ + httpRequest?: HttpRequestLike +} & ( + | { + mode: RefStateMode.Master + } + | { + mode: RefStateMode.ReleaseID + releaseID: string + } + | { + mode: RefStateMode.ReleaseLabel + releaseLabel: string + } + | { + mode: RefStateMode.Manual + ref: RefStringOrThunk + } +) + +/** + * A ref or a function that returns a ref. If a static ref is known, one can be + * given. If the ref must be fetched on-demand, a function can be provided. This + * function can optionally be asynchronous. + */ +type RefStringOrThunk = + | string + | (() => string | undefined | Promise) + +/** + * Configuration for clients that determine how content is queried. + */ +export type ClientConfig = { + /** + * The full Rest API V2 endpoint for the repository. This is only helpful if + * you're using Prismic behind a proxy which we do not recommend. + * + * @defaultValue `getRepositoryEndpoint(repositoryNameOrEndpoint)` + */ + documentAPIEndpoint?: string + + /** + * The secure token for accessing the Prismic repository. This is only + * required if the repository is set to private. + */ + accessToken?: string + + /** + * A string representing a version of the Prismic repository's content. This + * may point to the latest version (called the "master ref"), or a preview + * with draft content. + */ + ref?: RefStringOrThunk + + /** + * A list of route resolver objects that define how a document's `url` + * property is resolved. + * + * {@link https://prismic.io/docs/route-resolver} + */ + routes?: NonNullable + + /** + * The `brokenRoute` option allows you to define the route populated in the + * `url` property for broken link or content relationship fields. A broken + * link is a link or content relationship field whose linked document has been + * unpublished or deleted. + * + * {@link https://prismic.io/docs/route-resolver} + */ + brokenRoute?: NonNullable + + /** + * Default parameters that will be sent with each query. These parameters can + * be overridden on each query if needed. + */ + defaultParams?: Omit< + BuildQueryURLArgs, + "ref" | "integrationFieldsRef" | "accessToken" | "routes" | "brokenRoute" + > +} & BaseClientConfig + +/** + * Parameters specific to client methods that fetch all documents. These methods + * start with `getAll` (for example, `getAllByType`). + */ +type GetAllParams = { + /** + * Limit the number of documents queried. If a number is not provided, there + * will be no limit and all matching documents will be returned. + */ + limit?: number +} + +/** + * Arguments to determine how the URL for a preview session is resolved. + */ +type ResolvePreviewArgs = { + /** + * A function that maps a Prismic document to a URL within your app. + */ + linkResolver?: LinkResolverFunction + + /** + * A fallback URL if the link resolver does not return a value. + */ + defaultURL: string + + /** + * The preview token (also known as a ref) that will be used to query preview + * content from the Prismic repository. + */ + previewToken?: string + + /** + * The previewed document that will be used to determine the destination URL. + */ + documentID?: string +} + +/** + * A client that allows querying content from a Prismic repository. + * + * If used in an environment where a global `fetch` function is unavailable, + * such as Node.js, the `fetch` option must be provided as part of the `options` + * parameter. + * + * @typeParam TDocuments - Document types that are registered for the Prismic + * repository. Query methods will automatically be typed based on this type. + */ +export class Client< + TDocuments extends PrismicDocument = PrismicDocument, +> extends BaseClient { + #repositoryName: string | undefined + + /** + * The Prismic repository's name. + */ + set repositoryName(value: string) { + this.#repositoryName = value + } + + /** + * The Prismic repository's name. + */ + get repositoryName(): string { + if (!this.#repositoryName) { + throw new PrismicError( + `A repository name is required for this method but one could not be inferred from the provided API endpoint (\`${this.documentAPIEndpoint}\`). To fix this error, provide a repository name when creating the client. For more details, see ${devMsg("prefer-repository-name")}`, + undefined, + undefined, + ) + } + + return this.#repositoryName + } + + /** + * The Prismic REST API V2 endpoint for the repository (use + * `prismic.getRepositoryEndpoint` for the default endpoint). + */ + documentAPIEndpoint: string + + /** + * The Prismic REST API V2 endpoint for the repository (use + * `prismic.getRepositoryEndpoint` for the default endpoint). + * + * @deprecated Use `documentAPIEndpoint` instead. + */ + // TODO: Remove in v8. + set endpoint(value: string) { + this.documentAPIEndpoint = value + } + + /** + * The Prismic REST API V2 endpoint for the repository (use + * `prismic.getRepositoryEndpoint` for the default endpoint). + * + * @deprecated Use `documentAPIEndpoint` instead. + */ + // TODO: Remove in v8. + get endpoint(): string { + return this.documentAPIEndpoint + } + + /** + * The secure token for accessing the API (only needed if your repository is + * set to private). + * + * {@link https://user-guides.prismic.io/en/articles/1036153-generating-an-access-token} + */ + accessToken?: string + + /** + * A list of route resolver objects that define how a document's `url` field + * is resolved. + * + * {@link https://prismic.io/docs/route-resolver} + */ + routes?: NonNullable + + /** + * The `brokenRoute` option allows you to define the route populated in the + * `url` property for broken link or content relationship fields. A broken + * link is a link or content relationship field whose linked document has been + * unpublished or deleted. + * + * {@link https://prismic.io/docs/route-resolver} + */ + brokenRoute?: NonNullable + + /** + * Default parameters that will be sent with each query. These parameters can + * be overridden on each query if needed. + */ + defaultParams?: Omit< + BuildQueryURLArgs, + "ref" | "integrationFieldsRef" | "accessToken" | "routes" + > + + /** + * The client's ref mode state. This determines which ref is used during + * queries. + */ + private refState: RefState = { + mode: RefStateMode.Master, + autoPreviewsEnabled: true, + } + + /** + * Cached repository value. + */ + private cachedRepository: Repository | undefined + + /** + * Timestamp at which the cached repository data is considered stale. + */ + private cachedRepositoryExpiration = 0 + + /** + * Creates a Prismic client that can be used to query a repository. + * + * If used in an environment where a global `fetch` function is unavailable, + * such as in some Node.js versions, the `fetch` option must be provided as + * part of the `options` parameter. + * + * @param repositoryNameOrEndpoint - The Prismic repository name or full Rest + * API V2 endpoint for the repository. + * @param options - Configuration that determines how content will be queried + * from the Prismic repository. + * + * @returns A client that can query content from the repository. + */ + constructor(repositoryNameOrEndpoint: string, options: ClientConfig = {}) { + super(options) + + if ( + (options.documentAPIEndpoint || + isRepositoryEndpoint(repositoryNameOrEndpoint)) && + process.env.NODE_ENV === "development" + ) { + const documentAPIEndpoint = + options.documentAPIEndpoint || repositoryNameOrEndpoint + + // Matches non-API v2 `.prismic.io` endpoints, see: https://regex101.com/r/xRsavu/1 + if (/\.prismic\.io\/(?!api\/v2\/?)/i.test(documentAPIEndpoint)) { + throw new PrismicError( + "@prismicio/client only supports Prismic Rest API V2. Please provide only the repository name to the first createClient() parameter or use the getRepositoryEndpoint() helper to generate a valid Rest API V2 endpoint URL.", + undefined, + undefined, + ) + } + + const hostname = new URL(documentAPIEndpoint).hostname.toLowerCase() + + // Matches non-.cdn `.prismic.io` endpoints + if ( + hostname.endsWith(".prismic.io") && + !hostname.endsWith(".cdn.prismic.io") + ) { + console.warn( + `[@prismicio/client] The client was created with a non-CDN endpoint. Convert it to the CDN endpoint for better performance. For more details, see ${devMsg("endpoint-must-use-cdn")}`, + ) + } + + // Warn if the user provided both a repository endpoint and an `documentAPIEndpoint` and they are different + if ( + options.documentAPIEndpoint && + isRepositoryEndpoint(repositoryNameOrEndpoint) && + repositoryNameOrEndpoint !== options.documentAPIEndpoint + ) { + console.warn( + `[@prismicio/client] Multiple incompatible endpoints were provided. Create the client using a repository name to prevent this error. For more details, see ${devMsg("prefer-repository-name")}`, + ) + } + } + + if (isRepositoryEndpoint(repositoryNameOrEndpoint)) { + this.documentAPIEndpoint = repositoryNameOrEndpoint + try { + this.repositoryName = getRepositoryName(repositoryNameOrEndpoint) + } catch (error) { + console.warn( + `[@prismicio/client] A repository name could not be inferred from the provided endpoint (\`${repositoryNameOrEndpoint}\`). Some methods will be disabled. Create the client using a repository name to prevent this warning. For more details, see ${devMsg("prefer-repository-name")}`, + ) + } + } else { + this.documentAPIEndpoint = + options.documentAPIEndpoint || + getRepositoryEndpoint(repositoryNameOrEndpoint) + this.repositoryName = repositoryNameOrEndpoint + } + + this.accessToken = options.accessToken + this.routes = options.routes + this.brokenRoute = options.brokenRoute + this.defaultParams = options.defaultParams + + if (options.ref) { + this.queryContentFromRef(options.ref) + } + + this.graphQLFetch = this.graphQLFetch.bind(this) + } + + /** + * Enables the client to automatically query content from a preview session if + * one is active in browser environments. This is enabled by default in the + * browser. + * + * For server environments, use `enableAutoPreviewsFromReq`. + * + * @example + * + * ```ts + * client.enableAutoPreviews() + * ``` + * + * @see enableAutoPreviewsFromReq + */ + enableAutoPreviews(): void { + this.refState.autoPreviewsEnabled = true + } + + /** + * Enables the client to automatically query content from a preview session if + * one is active in server environments. This is disabled by default on the + * server. + * + * For browser environments, use `enableAutoPreviews`. + * + * @example + * + * ```ts + * // In an express app + * app.get("/", function (req, res) { + * client.enableAutoPreviewsFromReq(req) + * }) + * ``` + * + * @param req - An HTTP server request object containing the request's + * cookies. + */ + enableAutoPreviewsFromReq(req: R): void { + this.refState.httpRequest = req + this.refState.autoPreviewsEnabled = true + } + + /** + * Disables the client from automatically querying content from a preview + * session if one is active. + * + * Automatic preview content querying is enabled by default unless this method + * is called. + * + * @example + * + * ```ts + * client.disableAutoPreviews() + * ``` + */ + disableAutoPreviews(): void { + this.refState.autoPreviewsEnabled = false + } + + /** + * Queries content from the Prismic repository. + * + * @example + * + * ```ts + * const response = await client.get() + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param params - Parameters to filter, sort, and paginate results. + * + * @returns A paginated response containing the result of the query. + */ + async get( + params?: Partial & FetchParams, + ): Promise> { + const url = await this.buildQueryURL(params) + + return await this.fetch>(url, params) + } + + /** + * Queries content from the Prismic repository and returns only the first + * result, if any. + * + * @example + * + * ```ts + * const document = await client.getFirst() + * ``` + * + * @typeParam TDocument - Type of the Prismic document returned. + * + * @param params - Parameters to filter, sort, and paginate results. @returns + * The first result of the query, if any. + */ + async getFirst( + params?: Partial & FetchParams, + ): Promise { + const actualParams = { ...params } + if (!(params && params.page) && !params?.pageSize) { + actualParams.pageSize = this.defaultParams?.pageSize ?? 1 + } + const url = await this.buildQueryURL(actualParams) + const result = await this.fetch>(url, params) + + const firstResult = result.results[0] + + if (firstResult) { + return firstResult + } + + throw new NotFoundError("No documents were returned", url, undefined) + } + + /** + * **IMPORTANT**: Avoid using `dangerouslyGetAll` as it may be slower and + * require more resources than other methods. Prefer using other methods that + * filter by filters such as `getAllByType`. + * + * Queries content from the Prismic repository and returns all matching + * content. If no filters are provided, all documents will be fetched. + * + * This method may make multiple network requests to query all matching + * content. + * + * @example + * + * ```ts + * const response = await client.dangerouslyGetAll() + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param params - Parameters to filter, sort, and paginate results. + * + * @returns A list of documents matching the query. + */ + async dangerouslyGetAll( + params: Partial> & + GetAllParams & + FetchParams = {}, + ): Promise { + const { limit = Infinity, ...actualParams } = params + const resolvedParams = { + ...actualParams, + pageSize: Math.min( + limit, + actualParams.pageSize || this.defaultParams?.pageSize || MAX_PAGE_SIZE, + ), + } + + const documents: TDocument[] = [] + let latestResult: Query | undefined + + while ( + (!latestResult || latestResult.next_page) && + documents.length < limit + ) { + const page = latestResult ? latestResult.page + 1 : undefined + + latestResult = await this.get({ ...resolvedParams, page }) + documents.push(...latestResult.results) + + if (latestResult.next_page) { + await new Promise((res) => setTimeout(res, GET_ALL_QUERY_DELAY)) + } + } + + return documents.slice(0, limit) + } + + /** + * Queries a document from the Prismic repository with a specific ID. + * + * @remarks + * A document's UID is different from its ID. An ID is automatically generated + * for all documents and is made available on its `id` property. A UID is + * provided in the Prismic editor and is unique among all documents of its + * custom type. + * + * @example + * + * ```ts + * const document = await client.getByID("WW4bKScAAMAqmluX") + * ``` + * + * @typeParam TDocument- Type of the Prismic document returned. + * + * @param id - ID of the document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns The document with an ID matching the `id` parameter, if a matching + * document exists. + */ + async getByID( + id: string, + params?: Partial & FetchParams, + ): Promise { + return await this.getFirst( + appendFilters(params, filter.at("document.id", id)), + ) + } + + /** + * Queries documents from the Prismic repository with specific IDs. + * + * @remarks + * A document's UID is different from its ID. An ID is automatically generated + * for all documents and is made available on its `id` property. A UID is + * provided in the Prismic editor and is unique among all documents of its + * custom type. + * + * @example + * + * ```ts + * const response = await client.getByIDs([ + * "WW4bKScAAMAqmluX", + * "U1kTRgEAAC8A5ldS", + * ]) + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param ids - A list of document IDs. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A paginated response containing documents with IDs matching the + * `ids` parameter. + */ + async getByIDs( + ids: string[], + params?: Partial & FetchParams, + ): Promise> { + return await this.get( + appendFilters(params, filter.in("document.id", ids)), + ) + } + + /** + * Queries all documents from the Prismic repository with specific IDs. + * + * This method may make multiple network requests to query all matching + * content. + * + * @remarks + * A document's UID is different from its ID. An ID is automatically generated + * for all documents and is made available on its `id` property. A UID is + * provided in the Prismic editor and is unique among all documents of its + * custom type. + * + * @example + * + * ```ts + * const response = await client.getAllByIDs([ + * "WW4bKScAAMAqmluX", + * "U1kTRgEAAC8A5ldS", + * ]) + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param ids - A list of document IDs. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A list of documents with IDs matching the `ids` parameter. + */ + async getAllByIDs( + ids: string[], + params?: Partial & GetAllParams & FetchParams, + ): Promise { + return await this.dangerouslyGetAll( + appendFilters(params, filter.in("document.id", ids)), + ) + } + + /** + * Queries a document from the Prismic repository with a specific UID and + * custom type. + * + * @remarks + * A document's UID is different from its ID. An ID is automatically generated + * for all documents and is made available on its `id` property. A UID is + * provided in the Prismic editor and is unique among all documents of its + * custom type. + * + * @example + * + * ```ts + * const document = await client.getByUID("blog_post", "my-first-post") + * ``` + * + * @typeParam TDocument - Type of the Prismic document returned. + * + * @param documentType - The API ID of the document's custom type. + * @param uid - UID of the document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns The document with a UID matching the `uid` parameter, if a + * matching document exists. + */ + async getByUID< + TDocument extends TDocuments, + TDocumentType extends TDocument["type"] = TDocument["type"], + >( + documentType: TDocumentType, + uid: string, + params?: Partial & FetchParams, + ): Promise> { + return await this.getFirst>( + appendFilters(params, [ + typeFilter(documentType), + filter.at(`my.${documentType}.uid`, uid), + ]), + ) + } + + /** + * Queries document from the Prismic repository with specific UIDs and Custom + * Type. + * + * @remarks + * A document's UID is different from its ID. An ID is automatically generated + * for all documents and is made available on its `id` property. A UID is + * provided in the Prismic editor and is unique among all documents of its + * custom type. + * + * @example + * + * ```ts + * const document = await client.getByUIDs("blog_post", [ + * "my-first-post", + * "my-second-post", + * ]) + * ``` + * + * @typeParam TDocument - Type of the Prismic document returned. + * + * @param documentType - The API ID of the document's custom type. + * @param uids - A list of document UIDs. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A paginated response containing documents with UIDs matching the + * `uids` parameter. + */ + async getByUIDs< + TDocument extends TDocuments, + TDocumentType extends TDocument["type"] = TDocument["type"], + >( + documentType: TDocumentType, + uids: string[], + params?: Partial & FetchParams, + ): Promise>> { + return await this.get>( + appendFilters(params, [ + typeFilter(documentType), + filter.in(`my.${documentType}.uid`, uids), + ]), + ) + } + + /** + * Queries all documents from the Prismic repository with specific UIDs and + * custom type. + * + * This method may make multiple network requests to query all matching + * content. + * + * @remarks + * A document's UID is different from its ID. An ID is automatically generated + * for all documents and is made available on its `id` property. A UID is + * provided in the Prismic editor and is unique among all documents of its + * custom type. + * + * @example + * + * ```ts + * const response = await client.getAllByUIDs([ + * "my-first-post", + * "my-second-post", + * ]) + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param documentType - The API ID of the document's custom type. + * @param uids - A list of document UIDs. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A list of documents with UIDs matching the `uids` parameter. + */ + async getAllByUIDs< + TDocument extends TDocuments, + TDocumentType extends TDocument["type"] = TDocument["type"], + >( + documentType: TDocumentType, + uids: string[], + params?: Partial & GetAllParams & FetchParams, + ): Promise[]> { + return await this.dangerouslyGetAll< + ExtractDocumentType + >( + appendFilters(params, [ + typeFilter(documentType), + filter.in(`my.${documentType}.uid`, uids), + ]), + ) + } + + /** + * Queries a singleton document from the Prismic repository for a specific + * custom type. + * + * @remarks + * A singleton document is one that is configured in Prismic to only allow one + * instance. For example, a repository may be configured to contain just one + * Settings document. This is in contrast to a repeatable custom type which + * allows multiple instances of itself. + * + * @example + * + * ```ts + * const document = await client.getSingle("settings") + * ``` + * + * @typeParam TDocument - Type of the Prismic document returned. + * + * @param documentType - The API ID of the singleton custom type. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns The singleton document for the custom type, if a matching document + * exists. + */ + async getSingle< + TDocument extends TDocuments, + TDocumentType extends TDocument["type"] = TDocument["type"], + >( + documentType: TDocumentType, + params?: Partial & FetchParams, + ): Promise> { + return await this.getFirst>( + appendFilters(params, typeFilter(documentType)), + ) + } + + /** + * Queries documents from the Prismic repository for a specific custom type. + * + * Use `getAllByType` instead if you need to query all documents for a + * specific custom type. + * + * @example + * + * ```ts + * const response = await client.getByType("blog_post") + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param documentType - The API ID of the custom type. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A paginated response containing documents of the custom type. + */ + async getByType< + TDocument extends TDocuments, + TDocumentType extends TDocument["type"] = TDocument["type"], + >( + documentType: TDocumentType, + params?: Partial & FetchParams, + ): Promise>> { + return await this.get>( + appendFilters(params, typeFilter(documentType)), + ) + } + + /** + * Queries all documents from the Prismic repository for a specific Custom + * Type. + * + * This method may make multiple network requests to query all matching + * content. + * + * @example + * + * ```ts + * const response = await client.getByType("blog_post") + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param documentType - The API ID of the custom type. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A list of all documents of the custom type. + */ + async getAllByType< + TDocument extends TDocuments, + TDocumentType extends TDocument["type"] = TDocument["type"], + >( + documentType: TDocumentType, + params?: Partial> & + GetAllParams & + FetchParams, + ): Promise[]> { + return await this.dangerouslyGetAll< + ExtractDocumentType + >(appendFilters(params, typeFilter(documentType))) + } + + /** + * Queries documents from the Prismic repository with a specific tag. + * + * Use `getAllByTag` instead if you need to query all documents with a + * specific tag. + * + * @example + * + * ```ts + * const response = await client.getByTag("food") + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param tag - The tag that must be included on a document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A paginated response containing documents with the tag. + */ + async getByTag( + tag: string, + params?: Partial & FetchParams, + ): Promise> { + return await this.get(appendFilters(params, someTagsFilter(tag))) + } + + /** + * Queries all documents from the Prismic repository with a specific tag. + * + * This method may make multiple network requests to query all matching + * content. + * + * @example + * + * ```ts + * const response = await client.getAllByTag("food") + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param tag - The tag that must be included on a document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A list of all documents with the tag. + */ + async getAllByTag( + tag: string, + params?: Partial> & + GetAllParams & + FetchParams, + ): Promise { + return await this.dangerouslyGetAll( + appendFilters(params, someTagsFilter(tag)), + ) + } + + /** + * Queries documents from the Prismic repository with specific tags. A + * document must be tagged with all of the queried tags to be included. + * + * @example + * + * ```ts + * const response = await client.getByEveryTag(["food", "fruit"]) + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param tags - A list of tags that must be included on a document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A paginated response containing documents with the tags. + */ + async getByEveryTag( + tags: string[], + params?: Partial & FetchParams, + ): Promise> { + return await this.get( + appendFilters(params, everyTagFilter(tags)), + ) + } + + /** + * Queries documents from the Prismic repository with specific tags. A + * document must be tagged with all of the queried tags to be included. + * + * This method may make multiple network requests to query all matching + * content. + * + * @example + * + * ```ts + * const response = await client.getAllByEveryTag(["food", "fruit"]) + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param tags - A list of tags that must be included on a document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A list of all documents with the tags. + */ + async getAllByEveryTag( + tags: string[], + params?: Partial> & + GetAllParams & + FetchParams, + ): Promise { + return await this.dangerouslyGetAll( + appendFilters(params, everyTagFilter(tags)), + ) + } + + /** + * Queries documents from the Prismic repository with specific tags. A + * document must be tagged with at least one of the queried tags to be + * included. + * + * @example + * + * ```ts + * const response = await client.getByEveryTag(["food", "fruit"]) + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param tags - A list of tags that must be included on a document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A paginated response containing documents with at least one of the + * tags. + */ + async getBySomeTags( + tags: string[], + params?: Partial & FetchParams, + ): Promise> { + return await this.get( + appendFilters(params, someTagsFilter(tags)), + ) + } + + /** + * Queries documents from the Prismic repository with specific tags. A + * document must be tagged with at least one of the queried tags to be + * included. + * + * This method may make multiple network requests to query all matching + * content. + * + * @example + * + * ```ts + * const response = await client.getAllBySomeTags(["food", "fruit"]) + * ``` + * + * @typeParam TDocument - Type of Prismic documents returned. + * + * @param tags - A list of tags that must be included on a document. + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A list of all documents with at least one of the tags. + */ + async getAllBySomeTags( + tags: string[], + params?: Partial> & + GetAllParams & + FetchParams, + ): Promise { + return await this.dangerouslyGetAll( + appendFilters(params, someTagsFilter(tags)), + ) + } + + /** + * Returns metadata about the Prismic repository, such as its refs, releases, + * and custom types. + * + * @returns Repository metadata. + */ + async getRepository(params?: FetchParams): Promise { + // TODO: Restore when Authorization header support works in browsers with CORS. + // return await this.fetch(this.endpoint); + + const url = new URL(this.documentAPIEndpoint) + + if (this.accessToken) { + url.searchParams.set("access_token", this.accessToken) + } + + return await this.fetch(url.toString(), params) + } + + /** + * Returns a list of all refs for the Prismic repository. + * + * Refs are used to identify which version of the repository's content should + * be queried. All repositories will have at least one ref pointing to the + * latest published content called the "master ref". + * + * @returns A list of all refs for the Prismic repository. + */ + async getRefs(params?: FetchParams): Promise { + const repository = await this.getRepository(params) + + return repository.refs + } + + /** + * Returns a ref for the Prismic repository with a matching ID. + * + * @param id - ID of the ref. + * + * @returns The ref with a matching ID, if it exists. + */ + async getRefByID(id: string, params?: FetchParams): Promise { + const refs = await this.getRefs(params) + + return findRefByID(refs, id) + } + + /** + * Returns a ref for the Prismic repository with a matching label. + * + * @param label - Label of the ref. + * + * @returns The ref with a matching label, if it exists. + */ + async getRefByLabel(label: string, params?: FetchParams): Promise { + const refs = await this.getRefs(params) + + return findRefByLabel(refs, label) + } + + /** + * Returns the master ref for the Prismic repository. The master ref points to + * the repository's latest published content. + * + * @returns The repository's master ref. + */ + async getMasterRef(params?: FetchParams): Promise { + const refs = await this.getRefs(params) + + return findMasterRef(refs) + } + + /** + * Returns a list of all Releases for the Prismic repository. Releases are + * used to group content changes before publishing. + * + * @returns A list of all Releases for the Prismic repository. + */ + async getReleases(params?: FetchParams): Promise { + const refs = await this.getRefs(params) + + return refs.filter((ref) => !ref.isMasterRef) + } + + /** + * Returns a Release for the Prismic repository with a matching ID. + * + * @param id - ID of the Release. + * + * @returns The Release with a matching ID, if it exists. + */ + async getReleaseByID(id: string, params?: FetchParams): Promise { + const releases = await this.getReleases(params) + + return findRefByID(releases, id) + } + + /** + * Returns a Release for the Prismic repository with a matching label. + * + * @param label - Label of the ref. + * + * @returns The ref with a matching label, if it exists. + */ + async getReleaseByLabel(label: string, params?: FetchParams): Promise { + const releases = await this.getReleases(params) + + return findRefByLabel(releases, label) + } + + /** + * Returns a list of all tags used in the Prismic repository. + * + * @returns A list of all tags used in the repository. + */ + async getTags(params?: FetchParams): Promise { + try { + const tagsForm = await this.getCachedRepositoryForm("tags", params) + + const url = new URL(tagsForm.action) + + if (this.accessToken) { + url.searchParams.set("access_token", this.accessToken) + } + + return await this.fetch(url.toString(), params) + } catch { + const repository = await this.getRepository(params) + + return repository.tags + } + } + + /** + * Builds a URL used to query content from the Prismic repository. + * + * @param params - Parameters to filter, sort, and paginate the results. + * + * @returns A URL string that can be requested to query content. + */ + async buildQueryURL({ + signal, + fetchOptions, + ...params + }: Partial & FetchParams = {}): Promise { + const ref = + params.ref || (await this.getResolvedRefString({ signal, fetchOptions })) + const integrationFieldsRef = + params.integrationFieldsRef || + (await this.getCachedRepository({ signal, fetchOptions })) + .integrationFieldsRef || + undefined + + return buildQueryURL(this.documentAPIEndpoint, { + ...this.defaultParams, + ...params, + ref, + integrationFieldsRef, + routes: params.routes || this.routes, + brokenRoute: params.brokenRoute || this.brokenRoute, + accessToken: params.accessToken || this.accessToken, + }) + } + + /** + * Determines the URL for a previewed document during an active preview + * session. The result of this method should be used to redirect the user to + * the document's URL. + * + * @example + * + * ```ts + * const url = client.resolvePreviewURL({ + * linkResolver: (document) => `/${document.uid}` + * defaultURL: '/' + * }) + * ``` + * + * @param args - Arguments to configure the URL resolving. + * + * @returns The URL for the previewed document during an active preview + * session. The user should be redirected to this URL. + */ + async resolvePreviewURL( + args: ResolvePreviewArgs & FetchParams, + ): Promise { + let documentID: string | undefined | null = args.documentID + let previewToken: string | undefined | null = args.previewToken + + if (typeof globalThis.location !== "undefined") { + const searchParams = new URLSearchParams(globalThis.location.search) + + documentID = documentID || searchParams.get("documentId") + previewToken = previewToken || searchParams.get("token") + } else if (this.refState.httpRequest) { + if ("query" in this.refState.httpRequest) { + documentID = + documentID || (this.refState.httpRequest.query?.documentId as string) + previewToken = + previewToken || (this.refState.httpRequest.query?.token as string) + } else if ( + "url" in this.refState.httpRequest && + this.refState.httpRequest.url + ) { + // Including "missing-host://" by default + // handles a case where Next.js Route Handlers + // only provide the pathname and search + // parameters in the `url` property + // (e.g. `/api/preview?foo=bar`). + const searchParams = new URL( + this.refState.httpRequest.url, + "missing-host://", + ).searchParams + + documentID = documentID || searchParams.get("documentId") + previewToken = previewToken || searchParams.get("token") + } + } + + if (documentID != null && previewToken != null) { + const document = await this.getByID(documentID, { + ref: previewToken, + lang: "*", + signal: args.signal, + fetchOptions: args.fetchOptions, + }) + + const url = asLink(document, { linkResolver: args.linkResolver }) + + if (typeof url === "string") { + return url + } + } + + return args.defaultURL + } + + /** + * Configures the client to query the latest published content for all future + * queries. + * + * If the `ref` parameter is provided during a query, it takes priority for + * that query. + * + * @example + * + * ```ts + * await client.queryLatestContent() + * const document = await client.getByID("WW4bKScAAMAqmluX") + * ``` + */ + queryLatestContent(): void { + this.refState.mode = RefStateMode.Master + } + + /** + * Configures the client to query content from a specific Release identified + * by its ID for all future queries. + * + * If the `ref` parameter is provided during a query, it takes priority for + * that query. + * + * @example + * + * ```ts + * await client.queryContentFromReleaseByID("YLB7OBAAACMA7Cpa") + * const document = await client.getByID("WW4bKScAAMAqmluX") + * ``` + * + * @param releaseID - The ID of the Release. + */ + queryContentFromReleaseByID(releaseID: string): void { + this.refState = { + ...this.refState, + mode: RefStateMode.ReleaseID, + releaseID, + } + } + + /** + * Configures the client to query content from a specific Release identified + * by its label for all future queries. + * + * If the `ref` parameter is provided during a query, it takes priority for + * that query. + * + * @example + * + * ```ts + * await client.queryContentFromReleaseByLabel("My Release") + * const document = await client.getByID("WW4bKScAAMAqmluX") + * ``` + * + * @param releaseLabel - The label of the Release. + */ + queryContentFromReleaseByLabel(releaseLabel: string): void { + this.refState = { + ...this.refState, + mode: RefStateMode.ReleaseLabel, + releaseLabel, + } + } + + /** + * Configures the client to query content from a specific ref. The ref can be + * provided as a string or a function. + * + * If a function is provided, the ref is fetched lazily before each query. The + * function may also be asynchronous. + * + * @example + * + * ```ts + * await client.queryContentFromRef("my-ref") + * const document = await client.getByID("WW4bKScAAMAqmluX") + * ``` + * + * @param ref - The ref or a function that returns the ref from which to query + * content. + */ + queryContentFromRef(ref: RefStringOrThunk): void { + this.refState = { + ...this.refState, + mode: RefStateMode.Manual, + ref, + } + } + + /** + * A `fetch()` function to be used with GraphQL clients configured for + * Prismic's GraphQL API. It automatically applies the necessary `prismic-ref` + * and Authorization headers. Queries will automatically be minified by + * removing whitespace where possible. + * + * @example + * + * ```ts + * const graphQLClient = new ApolloClient({ + * link: new HttpLink({ + * uri: prismic.getGraphQLEndpoint(repositoryName), + * // Provide `client.graphQLFetch` as the fetch implementation. + * fetch: client.graphQLFetch, + * // Using GET is required. + * useGETForQueries: true, + * }), + * cache: new InMemoryCache(), + * }) + * ``` + * + * @param input - The `fetch()` `input` parameter. Only strings are supported. + * @param init - The `fetch()` `init` parameter. Only plain objects are + * supported. + * + * @returns The `fetch()` Response for the request. + * + * @experimental + */ + async graphQLFetch( + input: RequestInfo, + init?: Omit & { signal?: AbortSignalLike }, + ): Promise { + const cachedRepository = await this.getCachedRepository() + const ref = await this.getResolvedRefString() + + const unsanitizedHeaders: Record = { + "Prismic-ref": ref, + Authorization: this.accessToken ? `Token ${this.accessToken}` : "", + // Asserting `init.headers` is a Record since popular GraphQL + // libraries pass this as a Record. Header objects as input + // are unsupported. + ...(init ? (init.headers as Record) : {}), + } + + if (cachedRepository.integrationFieldsRef) { + unsanitizedHeaders["Prismic-integration-field-ref"] = + cachedRepository.integrationFieldsRef + } + + // Normalize header keys to lowercase. This prevents header + // conflicts between the Prismic client and the GraphQL + // client. + const headers: Record = {} + for (const key in unsanitizedHeaders) { + if (unsanitizedHeaders[key]) { + headers[key.toLowerCase()] = + unsanitizedHeaders[key as keyof typeof unsanitizedHeaders] + } + } + + const url = new URL( + // Asserting `input` is a string since popular GraphQL + // libraries pass this as a string. Request objects as + // input are unsupported. + input as string, + ) + + // This prevents the request from being cached unnecessarily. + // Without adding this `ref` param, re-running a query + // could return a locally cached response, even if the + // `ref` changed. This happens because the URL is + // identical when the `ref` is not included. Caches may ignore + // headers. + // + // The Prismic GraphQL API ignores the `ref` param. + url.searchParams.set("ref", ref) + + const query = url.searchParams.get("query") + if (query) { + url.searchParams.set( + "query", + // Compress the GraphQL query (if it exists) by + // removing whitespace. This is done to + // optimize the query size and avoid + // hitting the upper limit of GET requests + // (2048 characters). + minifyGraphQLQuery(query), + ) + } + + return (await this.fetchFn(url.toString(), { + ...init, + headers, + })) as Response + } + + /** + * Returns a cached version of `getRepository` with a TTL. + * + * @returns Cached repository metadata. + */ + private async getCachedRepository(params?: FetchParams): Promise { + if ( + !this.cachedRepository || + Date.now() >= this.cachedRepositoryExpiration + ) { + this.cachedRepositoryExpiration = Date.now() + REPOSITORY_CACHE_TTL + this.cachedRepository = await this.getRepository(params) + } + + return this.cachedRepository + } + + /** + * Returns a cached Prismic repository form. Forms are used to determine API + * endpoints for types of repository data. + * + * @param name - Name of the form. + * + * @returns The repository form. + * + * @throws If a matching form cannot be found. + */ + private async getCachedRepositoryForm( + name: string, + params?: FetchParams, + ): Promise
{ + const cachedRepository = await this.getCachedRepository(params) + const form = cachedRepository.forms[name] + + if (!form) { + throw new PrismicError( + `Form with name "${name}" could not be found`, + undefined, + undefined, + ) + } + + return form + } + + /** + * Returns the ref needed to query based on the client's current state. This + * method may make a network request to fetch a ref or resolve the user's ref + * thunk. + * + * If auto previews are enabled, the preview ref takes priority if available. + * + * The following strategies are used depending on the client's state: + * + * - If the user called `queryLatestContent`: Use the repository's master ref. + * The ref is cached for 5 seconds. After 5 seconds, a new master ref is + * fetched. + * - If the user called `queryContentFromReleaseByID`: Use the release's ref. + * The ref is cached for 5 seconds. After 5 seconds, a new ref for the + * release is fetched. + * - If the user called `queryContentFromReleaseByLabel`: Use the release's ref. + * The ref is cached for 5 seconds. After 5 seconds, a new ref for the + * release is fetched. + * - If the user called `queryContentFromRef`: Use the provided ref. Fall back + * to the master ref if the ref is not a string. + * + * @returns The ref to use during a query. + */ + private async getResolvedRefString(params?: FetchParams): Promise { + if (this.refState.autoPreviewsEnabled) { + let previewRef: string | undefined + + let cookieJar: string | null | undefined + + if (this.refState.httpRequest?.headers) { + if ( + "get" in this.refState.httpRequest.headers && + typeof this.refState.httpRequest.headers.get === "function" + ) { + // Web API Headers + cookieJar = this.refState.httpRequest.headers.get("cookie") + } else if ("cookie" in this.refState.httpRequest.headers) { + // Express-style headers + cookieJar = this.refState.httpRequest.headers.cookie + } + } else if (globalThis.document?.cookie) { + cookieJar = globalThis.document.cookie + } + + if (cookieJar) { + previewRef = getPreviewCookie(cookieJar) + } + + if (previewRef) { + return previewRef + } + } + + const cachedRepository = await this.getCachedRepository(params) + + const refModeType = this.refState.mode + if (refModeType === RefStateMode.ReleaseID) { + return findRefByID(cachedRepository.refs, this.refState.releaseID).ref + } else if (refModeType === RefStateMode.ReleaseLabel) { + return findRefByLabel(cachedRepository.refs, this.refState.releaseLabel) + .ref + } else if (refModeType === RefStateMode.Manual) { + const res = await castThunk(this.refState.ref)() + + if (typeof res === "string") { + return res + } + } + + return findMasterRef(cachedRepository.refs).ref + } + + /** + * Performs a network request using the configured `fetch` function. It + * assumes all successful responses will have a JSON content type. It also + * normalizes unsuccessful network requests. + * + * @typeParam T - The JSON response. + * + * @param url - URL to the resource to fetch. + * @param params - Prismic REST API parameters for the network request. + * + * @returns The JSON response from the network request. + */ + protected async fetch( + url: string, + params: FetchParams = {}, + ): Promise { + const res = await super.fetch(url, params) + + if (res.status !== 404 && res.status !== 429 && res.json == null) { + throw new PrismicError(undefined, url, res.json || res.text) + } + + switch (res.status) { + // Successful + case 200: + case 201: { + return res.json + } + + // Bad Request + // - Invalid filter syntax + // - Ref not provided (ignored) + case 400: { + throw new ParsingError(res.json.message, url, res.json) + } + + // Unauthorized + // - Missing access token for repository endpoint + // - Incorrect access token for repository endpoint + case 401: + // Forbidden + // - Missing access token for query endpoint + // - Incorrect access token for query endpoint + case 403: { + throw new ForbiddenError( + res.json.error || res.json.message, + url, + res.json, + ) + } + + // Not Found + // - Incorrect repository name (this response has an empty body) + // - Ref does not exist + // - Preview token is expired + case 404: { + if (res.json === undefined) { + throw new RepositoryNotFoundError( + `Prismic repository not found. Check that "${this.documentAPIEndpoint}" is pointing to the correct repository.`, + url, + url.startsWith(this.documentAPIEndpoint) ? undefined : res.text, + ) + } + + if (res.json.type === "api_notfound_error") { + throw new RefNotFoundError(res.json.message, url, res.json) + } + + if ( + res.json.type === "api_security_error" && + /preview token.*expired/i.test(res.json.message) + ) { + throw new PreviewTokenExpiredError(res.json.message, url, res.json) + } + + throw new NotFoundError(res.json.message, url, res.json) + } + + // Gone + // - Ref is expired + case 410: { + throw new RefExpiredError(res.json.message, url, res.json) + } + + // Too Many Requests + // - Exceeded the maximum number of requests per second + case 429: { + const parsedRetryAfter = Number(res.headers.get("retry-after")) + const delay = Number.isNaN(parsedRetryAfter) + ? DEFUALT_RETRY_AFTER_MS + : parsedRetryAfter + + return await new Promise((resolve, reject) => { + setTimeout(async () => { + try { + resolve(await this.fetch(url, params)) + } catch (error) { + reject(error) + } + }, delay) + }) + } + } + + throw new PrismicError(undefined, url, res.json) + } +} diff --git a/src/Migration.ts b/src/Migration.ts new file mode 100644 index 00000000..0d673441 --- /dev/null +++ b/src/Migration.ts @@ -0,0 +1,531 @@ +import * as is from "./lib/isValue" +import { validateAssetMetadata } from "./lib/validateAssetMetadata" + +import type { Asset } from "./types/api/asset/asset" +import type { + MigrationAssetConfig, + MigrationImage, + MigrationLinkToMedia, + MigrationRTImageNode, +} from "./types/migration/Asset" +import { PrismicMigrationAsset } from "./types/migration/Asset" +import type { MigrationContentRelationship } from "./types/migration/ContentRelationship" +import { PrismicMigrationDocument } from "./types/migration/Document" +import type { + ExistingPrismicDocument, + PendingPrismicDocument, +} from "./types/migration/Document" +import type { PrismicDocument } from "./types/value/document" +import type { FilledImageFieldImage } from "./types/value/image" +import { type FilledLinkToWebField, LinkType } from "./types/value/link" +import type { FilledLinkToMediaField } from "./types/value/linkToMedia" +import { RichTextNodeType } from "./types/value/richText" + +/** + * Extracts one or more Prismic document types that match a given Prismic + * document type. If no matches are found, no extraction is performed and the + * union of all provided Prismic document types are returned. + * + * @typeParam TDocuments - Prismic document types from which to extract. + * @typeParam TDocumentType - Type(s) to match `TDocuments` against. + */ +type ExtractDocumentType< + TDocuments extends { type: string }, + TDocumentType extends TDocuments["type"], +> = + Extract extends never + ? TDocuments + : Extract + +/** + * A helper that allows preparing your migration to Prismic. + * + * @typeParam TDocuments - Document types that are registered for the Prismic + * repository. Query methods will automatically be typed based on this type. + */ +export class Migration { + /** + * Assets registered in the migration. + * + * @internal + */ + _assets: Map = new Map() + + /** + * Documents registered in the migration. + * + * @internal + */ + _documents: PrismicMigrationDocument[] = [] + + /** + * Registers an asset to be created in the migration from an asset object. + * + * @remarks + * This method does not create the asset in Prismic media library right away. + * Instead, it registers it in your migration. The asset will be created when + * the migration is executed through the `writeClient.migrate()` method. + * + * @param asset - An asset object from Prismic Asset API. + * + * @returns A migration asset field instance. + * + * @internal + */ + createAsset(asset: Asset): PrismicMigrationAsset + + /** + * Registers an asset to be created in the migration from an image or link to + * media field. + * + * @remarks + * This method does not create the asset in Prismic media library right away. + * Instead, it registers it in your migration. The asset will be created when + * the migration is executed through the `writeClient.migrate()` method. + * + * @param imageOrLinkToMediaField - An image or link to media field from + * Prismic Document API. + * + * @returns A migration asset field instance. + * + * @internal + */ + createAsset( + imageOrLinkToMediaField: FilledImageFieldImage | FilledLinkToMediaField, + ): PrismicMigrationAsset + + /** + * Registers an asset to be created in the migration from a file. + * + * @remarks + * This method does not create the asset in Prismic media library right away. + * Instead, it registers it in your migration. The asset will be created when + * the migration is executed through the `writeClient.migrate()` method. + * + * @param file - The URL or content of the file to be created. + * @param filename - The filename of the asset. + * @param params - Additional asset data. + * + * @returns A migration asset field instance. + */ + createAsset( + file: MigrationAssetConfig["file"], + filename: MigrationAssetConfig["filename"], + params?: { + notes?: string + credits?: string + alt?: string + tags?: string[] + }, + ): PrismicMigrationAsset + + /** + * Registers an asset to be created in the migration from a file, an asset + * object, or an image or link to media field. + * + * @remarks + * This method does not create the asset in Prismic media library right away. + * Instead, it registers it in your migration. The asset will be created when + * the migration is executed through the `writeClient.migrate()` method. + * + * @returns A migration asset field instance. + */ + createAsset( + fileOrAssetOrField: + | MigrationAssetConfig["file"] + | Asset + | FilledImageFieldImage + | FilledLinkToMediaField, + filename?: MigrationAssetConfig["filename"], + { + notes, + credits, + alt, + tags, + }: { + notes?: string + credits?: string + alt?: string + tags?: string[] + } = {}, + ): PrismicMigrationAsset { + let config: MigrationAssetConfig + let maybeInitialField: FilledImageFieldImage | undefined + if (typeof fileOrAssetOrField === "object" && "url" in fileOrAssetOrField) { + if ( + "dimensions" in fileOrAssetOrField || + "link_type" in fileOrAssetOrField + ) { + const url = fileOrAssetOrField.url.split("?")[0] + const filename = + "name" in fileOrAssetOrField + ? fileOrAssetOrField.name + : url.split("/").pop()!.split("_").pop()! + const credits = + "copyright" in fileOrAssetOrField && fileOrAssetOrField.copyright + ? fileOrAssetOrField.copyright + : undefined + const alt = + "alt" in fileOrAssetOrField && fileOrAssetOrField.alt + ? fileOrAssetOrField.alt + : undefined + + if ("dimensions" in fileOrAssetOrField) { + maybeInitialField = fileOrAssetOrField + } + + config = { + id: fileOrAssetOrField.id, + file: url, + filename, + notes: undefined, + credits, + alt, + tags: undefined, + } + } else { + config = { + id: fileOrAssetOrField.id, + file: fileOrAssetOrField.url, + filename: fileOrAssetOrField.filename, + notes: fileOrAssetOrField.notes, + credits: fileOrAssetOrField.credits, + alt: fileOrAssetOrField.alt, + tags: fileOrAssetOrField.tags?.map(({ name }) => name), + } + } + } else { + config = { + id: fileOrAssetOrField, + file: fileOrAssetOrField, + filename: filename!, + notes, + credits, + alt, + tags, + } + } + + validateAssetMetadata(config) + + // We create a detached instance of the asset each time to serialize it properly + const migrationAsset = new PrismicMigrationAsset(config, maybeInitialField) + + const maybeAsset = this._assets.get(config.id) + if (maybeAsset) { + // Consolidate existing asset with new asset value if possible + maybeAsset.config.notes = maybeAsset.config.notes || config.notes + maybeAsset.config.credits = maybeAsset.config.credits || config.credits + maybeAsset.config.alt = maybeAsset.config.alt || config.alt + maybeAsset.config.tags = Array.from( + new Set([...(maybeAsset.config.tags || []), ...(config.tags || [])]), + ) + } else { + this._assets.set(config.id, migrationAsset) + } + + return migrationAsset + } + + /** + * Registers a document to be created in the migration. + * + * @remarks + * This method does not create the document in Prismic right away. Instead, it + * registers it in your migration. The document will be created when the + * migration is executed through the `writeClient.migrate()` method. + * + * @typeParam TType - Type of the Prismic document to create. + * + * @param document - The document to create. + * @param title - The title of the document to create which will be displayed + * in the editor. + * @param params - Document master language document ID. + * + * @returns A migration document instance. + */ + createDocument( + document: ExtractDocumentType, TType>, + title: string, + params?: { + masterLanguageDocument?: MigrationContentRelationship + }, + ): PrismicMigrationDocument> { + const doc = new PrismicMigrationDocument< + ExtractDocumentType + >(document, title, params) + + this._documents.push(doc) + + return doc + } + + /** + * Registers an existing document to be updated in the migration. + * + * @remarks + * This method does not update the document in Prismic right away. Instead, it + * registers it in your migration. The document will be updated when the + * migration is executed through the `writeClient.migrate()` method. + * + * @typeParam TType - Type of Prismic documents to update. + * + * @param document - The document to update. + * @param title - The title of the document to update which will be displayed + * in the editor. + * + * @returns A migration document instance. + */ + updateDocument( + document: ExtractDocumentType, TType>, + // Title is optional for existing documents as we might not want to update it. + title?: string, + ): PrismicMigrationDocument> { + const doc = new PrismicMigrationDocument< + ExtractDocumentType + >(document, title) + + this._documents.push(doc) + + return doc + } + + /** + * Registers a document from another Prismic repository to be created in the + * migration. + * + * @remarks + * This method does not create the document in Prismic right away. Instead, it + * registers it in your migration. The document will be created when the + * migration is executed through the `writeClient.migrate()` method. + * + * @param document - The document from Prismic to create. + * @param title - The title of the document to create which will be displayed + * in the editor. + * + * @returns A migration document instance. + */ + createDocumentFromPrismic( + document: ExtractDocumentType, TType>, + title: string, + ): PrismicMigrationDocument> { + const doc = new PrismicMigrationDocument( + this.#migratePrismicDocumentData({ + type: document.type, + lang: document.lang, + uid: document.uid, + tags: document.tags, + data: document.data, + }) as PendingPrismicDocument>, + title, + { originalPrismicDocument: document }, + ) + + this._documents.push(doc) + + return doc + } + + /** + * Queries a document from the migration instance with a specific UID and + * custom type. + * + * @example + * + * ```ts + * const contentRelationship = migration.createContentRelationship(() => + * migration.getByUID("blog_post", "my-first-post"), + * ) + * ``` + * + * @typeParam TType - Type of the Prismic document returned. + * + * @param type - The API ID of the document's custom type. + * @param uid - The UID of the document. + * + * @returns The migration document instance with a UID matching the `uid` + * parameter, if a matching document is found. + */ + getByUID( + type: TType, + uid: string, + ): + | PrismicMigrationDocument> + | undefined { + return this._documents.find( + ( + doc, + ): doc is PrismicMigrationDocument< + ExtractDocumentType + > => doc.document.type === type && doc.document.uid === uid, + ) + } + + /** + * Queries a singleton document from the migration instance for a specific + * custom type. + * + * @example + * + * ```ts + * const contentRelationship = migration.createContentRelationship(() => + * migration.getSingle("settings"), + * ) + * ``` + * + * @typeParam TType - Type of the Prismic document returned. + * + * @param type - The API ID of the singleton custom type. + * + * @returns The migration document instance for the custom type, if a matching + * document is found. + */ + getSingle( + type: TType, + ): + | PrismicMigrationDocument> + | undefined { + return this._documents.find( + ( + doc, + ): doc is PrismicMigrationDocument< + ExtractDocumentType + > => doc.document.type === type, + ) + } + + /** + * Migrates a Prismic document data from another repository so that it can be + * created through the current repository's Migration API. + * + * @param input - The Prismic document data to migrate. + * + * @returns The migrated Prismic document data. + */ + #migratePrismicDocumentData(input: unknown): unknown { + if (is.filledContentRelationship(input)) { + if (input.isBroken) { + return { + link_type: LinkType.Document, + // ID needs to be 16 characters long to be considered valid by the API + id: "_____broken_____", + isBroken: true, + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + text: input.text, + } + } + + return { + link_type: LinkType.Document, + id: () => this._getByOriginalID(input.id), + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + text: input.text, + } + } + + if (is.filledLinkToMedia(input)) { + return { + link_type: LinkType.Media, + id: this.createAsset(input), + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + text: input.text, + } + } + + if (is.rtImageNode(input)) { + // Rich text image nodes + const rtImageNode: MigrationRTImageNode = { + type: RichTextNodeType.image, + id: this.createAsset(input), + } + + if (input.linkTo) { + rtImageNode.linkTo = this.#migratePrismicDocumentData(input.linkTo) as + | MigrationContentRelationship + | MigrationLinkToMedia + | FilledLinkToWebField + } + + return rtImageNode + } + + if (is.filledImage(input)) { + const image: MigrationImage = { + id: this.createAsset(input), + } + + const { + id: _id, + url: _url, + dimensions: _dimensions, + edit: _edit, + alt: _alt, + copyright: _copyright, + ...thumbnails + } = input + + for (const name in thumbnails) { + if (is.filledImage(thumbnails[name])) { + image[name] = this.createAsset(thumbnails[name]) + } + } + + return image + } + + if (Array.isArray(input)) { + return input.map((element) => this.#migratePrismicDocumentData(element)) + } + + if (input && typeof input === "object") { + const res: Record = {} + + for (const key in input) { + res[key] = this.#migratePrismicDocumentData( + input[key as keyof typeof input], + ) + } + + return res + } + + return input + } + + /** + * Queries a document from the migration instance for a specific original ID. + * + * @example + * + * ```ts + * const contentRelationship = migration.createContentRelationship(() => + * migration._getByOriginalID("YhdrDxIAACgAcp_b"), + * ) + * ``` + * + * @typeParam TType - Type of the Prismic document returned. + * + * @param id - The original ID of the Prismic document. + * + * @returns The migration document instance for the original ID, if a matching + * document is found. + * + * @internal + */ + _getByOriginalID( + id: string, + ): + | PrismicMigrationDocument> + | undefined { + return this._documents.find( + ( + doc, + ): doc is PrismicMigrationDocument< + ExtractDocumentType + > => doc.originalPrismicDocument?.id === id, + ) + } +} diff --git a/src/WriteClient.ts b/src/WriteClient.ts new file mode 100644 index 00000000..02a45fd3 --- /dev/null +++ b/src/WriteClient.ts @@ -0,0 +1,916 @@ +import { devMsg } from "./lib/devMsg" +import { pLimit } from "./lib/pLimit" +import { + resolveMigrationContentRelationship, + resolveMigrationDocumentData, +} from "./lib/resolveMigrationDocumentData" + +import type { + Asset, + PatchAssetParams, + PatchAssetResult, + PostAssetParams, + PostAssetResult, +} from "./types/api/asset/asset" +import type { + AssetTag, + GetAssetTagsResult, + PostAssetTagParams, + PostAssetTagResult, +} from "./types/api/asset/tag" +import type { PutDocumentResult } from "./types/api/migration/document" +import { + type PostDocumentParams, + type PostDocumentResult, + type PutDocumentParams, +} from "./types/api/migration/document" +import type { PrismicMigrationAsset } from "./types/migration/Asset" +import type { + MigrationDocument, + PendingPrismicDocument, + PrismicMigrationDocument, +} from "./types/migration/Document" +import type { PrismicDocument } from "./types/value/document" + +import { PrismicError } from "./errors/PrismicError" + +import type { FetchParams, RequestInitLike } from "./BaseClient" +import { Client } from "./Client" +import type { ClientConfig } from "./Client" +import type { Migration } from "./Migration" +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { createMigration } from "./createMigration" + +/** + * Extracts one or more Prismic document types that match a given Prismic + * document type. If no matches are found, no extraction is performed and the + * union of all provided Prismic document types are returned. + * + * @typeParam TDocuments - Prismic document types from which to extract. + * @typeParam TDocumentType - Type(s) to match `TDocuments` against. + */ +type ExtractDocumentType< + TDocuments extends { type: string }, + TDocumentType extends TDocuments["type"], +> = + Extract extends never + ? TDocuments + : Extract + +/** + * Utility type to construct events reported by the migration process. + */ +type MigrateReporterEvent< + TType extends string, + TData = never, +> = TData extends never + ? { type: TType } + : { + type: TType + data: TData + } + +/** + * A map of event types and their data reported by the migration process. + */ +type MigrateReporterEventMap = { + start: { + pending: { + documents: number + assets: number + } + } + end: { + migrated: { + documents: number + assets: number + } + } + "assets:creating": { + current: number + remaining: number + total: number + asset: PrismicMigrationAsset + } + "assets:created": { + created: number + } + "documents:masterLocale": { + masterLocale: string + } + "documents:creating": { + current: number + remaining: number + total: number + document: PrismicMigrationDocument + } + "documents:created": { + created: number + } + "documents:updating": { + current: number + remaining: number + total: number + document: PrismicMigrationDocument + } + "documents:updated": { + updated: number + } +} + +/** + * Available event types reported by the migration process. + */ +type MigrateReporterEventTypes = keyof MigrateReporterEventMap + +/** + * All events reported by the migration process. Events can be listened to by + * providing a `reporter` function to the `migrate` method. + */ +export type MigrateReporterEvents = { + [K in MigrateReporterEventTypes]: MigrateReporterEvent< + K, + MigrateReporterEventMap[K] + > +}[MigrateReporterEventTypes] + +/** + * Additional parameters for creating an asset in the Prismic media library. + */ +export type CreateAssetParams = { + /** + * Asset notes. + */ + notes?: string + + /** + * Asset credits. + */ + credits?: string + + /** + * Asset alt text. + */ + alt?: string + + /** + * Asset tags. + */ + tags?: string[] +} + +/** + * Prismic Migration API demo keys. + */ +const MIGRATION_API_DEMO_KEYS = [ + "cSaZlfkQlF9C6CEAM2Del6MNX9WonlV86HPbeEJL", + "pZCexCajUQ4jriYwIGSxA1drZrFxDyFf1S0D1K0P", + "Yc0mfrkGDw8gaaGKTrzwC3QUZDajv6k73DA99vWN", + "ySzSEbVMAb5S1oSCQfbVG4mbh9Cb8wlF7BCvKI0L", + "g2DA3EKWvx8uxVYcNFrmT5nJpon1Vi9V4XcOibJD", + "CCNIlI0Vz41J66oFwsHUXaZa6NYFIY6z7aDF62Bc", +] + +/** + * Configuration for clients that determine how content is queried. + */ +export type WriteClientConfig = { + /** + * A Prismic write token that allows writing content to the repository. + */ + writeToken: string + + /** + * A Prismic Migration API key that allows working with the Migration API. + * + * @remarks + * If no key is provided, the client will use one of the demo key available + * which has stricter rate limiting rules enforced. + */ + migrationAPIKey?: string + + /** + * The Prismic Asset API endpoint. + * + * @defaultValue `"https://asset-api.prismic.io/"` + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ + assetAPIEndpoint?: string + + /** + * The Prismic Migration API endpoint. + * + * @defaultValue `"https://migration.prismic.io/"` + * + * @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference} + */ + migrationAPIEndpoint?: string +} & ClientConfig + +/** + * A client that allows querying and writing content to a Prismic repository. + * + * If used in an environment where a global `fetch` function is unavailable, + * such as Node.js, the `fetch` option must be provided as part of the `options` + * parameter. + * + * @typeParam TDocuments - Document types that are registered for the Prismic + * repository. Query methods will automatically be typed based on this type. + */ +export class WriteClient< + TDocuments extends PrismicDocument = PrismicDocument, +> extends Client { + writeToken: string + migrationAPIKey: string + + assetAPIEndpoint = "https://asset-api.prismic.io/" + migrationAPIEndpoint = "https://migration.prismic.io/" + + /** + * Creates a Prismic client that can be used to query and write content to a + * repository. + * + * If used in an environment where a global `fetch` function is unavailable, + * such as in some Node.js versions, the `fetch` option must be provided as + * part of the `options` parameter. + * + * @param repositoryName - The Prismic repository name for the repository. + * @param options - Configuration that determines how content will be queried + * from and written to the Prismic repository. + * + * @returns A client that can query and write content to the repository. + */ + constructor(repositoryName: string, options: WriteClientConfig) { + super(repositoryName, options) + + if (typeof globalThis.window !== "undefined") { + console.warn( + `[@prismicio/client] Prismic write client appears to be running in a browser environment. This is not recommended as it exposes your write token and Migration API key. Consider using Prismic write client in a server environment only, preferring the regular client for browser environement. For more details, see ${devMsg("avoid-write-client-in-browser")}`, + ) + } + + this.writeToken = options.writeToken + this.migrationAPIKey = + options.migrationAPIKey || + MIGRATION_API_DEMO_KEYS[ + Math.floor(Math.random() * MIGRATION_API_DEMO_KEYS.length) + ] + + if (options.assetAPIEndpoint) { + this.assetAPIEndpoint = `${options.assetAPIEndpoint}/` + } + + if (options.migrationAPIEndpoint) { + this.migrationAPIEndpoint = `${options.migrationAPIEndpoint}/` + } + } + + /** + * Creates a migration release on the Prismic repository based on the provided + * prepared migration. + * + * @param migration - A migration prepared with {@link createMigration}. + * @param params - An event listener and additional fetch parameters. + * + * @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference} + */ + async migrate( + migration: Migration, + params: { + reporter?: (event: MigrateReporterEvents) => void + } & FetchParams = {}, + ): Promise { + params.reporter?.({ + type: "start", + data: { + pending: { + documents: migration._documents.length, + assets: migration._assets.size, + }, + }, + }) + + await this.migrateCreateAssets(migration, params) + await this.migrateCreateDocuments(migration, params) + await this.migrateUpdateDocuments(migration, params) + + params.reporter?.({ + type: "end", + data: { + migrated: { + documents: migration._documents.length, + assets: migration._assets.size, + }, + }, + }) + } + + /** + * Creates assets in the Prismic repository's media library. + * + * @param migration - A migration prepared with {@link createMigration}. + * @param params - An event listener and additional fetch parameters. + * + * @internal This method is one of the step performed by the {@link migrate} method. + */ + private async migrateCreateAssets( + migration: Migration, + { + reporter, + ...fetchParams + }: { reporter?: (event: MigrateReporterEvents) => void } & FetchParams = {}, + ): Promise { + let created = 0 + for (const [_, migrationAsset] of migration._assets) { + reporter?.({ + type: "assets:creating", + data: { + current: ++created, + remaining: migration._assets.size - created, + total: migration._assets.size, + asset: migrationAsset, + }, + }) + + const { file, filename, notes, credits, alt, tags } = + migrationAsset.config + + let resolvedFile: PostAssetParams["file"] | File + if (typeof file === "string") { + let url: URL | undefined + try { + url = new URL(file) + } catch (error) { + // noop only on invalid URL, fetch errors will throw in the next if statement + } + + if (url) { + // File is a URL, fetch it + resolvedFile = await this.fetchForeignAsset( + url.toString(), + fetchParams, + ) + } else { + // File is actual file content, use it as-is + resolvedFile = file + } + } else if (file instanceof URL) { + // File is a URL instance, fetch it + resolvedFile = await this.fetchForeignAsset( + file.toString(), + fetchParams, + ) + } else { + resolvedFile = file + } + + const asset = await this.createAsset(resolvedFile, filename, { + ...{ notes, credits, alt, tags }, + ...fetchParams, + }) + + migrationAsset.asset = asset + } + + reporter?.({ + type: "assets:created", + data: { + created, + }, + }) + } + + /** + * Creates documents in the Prismic repository's migration release. + * + * @param migration - A migration prepared with {@link createMigration}. + * @param params - An event listener and additional fetch parameters. + * + * @internal This method is one of the step performed by the {@link migrate} method. + */ + private async migrateCreateDocuments( + migration: Migration, + { + reporter, + ...fetchParams + }: { reporter?: (event: MigrateReporterEvents) => void } & FetchParams = {}, + ): Promise { + // Resolve master locale + const repository = await this.getRepository(fetchParams) + const masterLocale = repository.languages.find((lang) => lang.is_master)!.id + reporter?.({ + type: "documents:masterLocale", + data: { + masterLocale, + }, + }) + + const documentsToCreate: PrismicMigrationDocument[] = [] + // We create an array with non-master locale documents last because + // we need their master locale document to be created first. + for (const doc of migration._documents) { + if (!doc.document.id) { + if (doc.document.lang === masterLocale) { + documentsToCreate.unshift(doc) + } else { + documentsToCreate.push(doc) + } + } + } + + let created = 0 + for (const doc of documentsToCreate) { + reporter?.({ + type: "documents:creating", + data: { + current: ++created, + remaining: documentsToCreate.length - created, + total: documentsToCreate.length, + document: doc, + }, + }) + + // Resolve master language document ID for non-master locale documents + let masterLanguageDocumentID: string | undefined + if (doc.masterLanguageDocument) { + const masterLanguageDocument = + await resolveMigrationContentRelationship(doc.masterLanguageDocument) + + masterLanguageDocumentID = + "id" in masterLanguageDocument ? masterLanguageDocument.id : undefined + } else if (doc.originalPrismicDocument) { + const maybeOriginalID = + doc.originalPrismicDocument.alternate_languages.find( + ({ lang }) => lang === masterLocale, + )?.id + + if (maybeOriginalID) { + masterLanguageDocumentID = + migration._getByOriginalID(maybeOriginalID)?.document.id + } + } + + const { id } = await this.createDocument( + // We'll upload documents data later on. + { ...doc.document, data: {} }, + doc.title!, + { + masterLanguageDocumentID, + ...fetchParams, + }, + ) + + doc.document.id = id + } + + reporter?.({ + type: "documents:created", + data: { created }, + }) + } + + /** + * Updates documents in the Prismic repository's migration release with their + * patched data. + * + * @param migration - A migration prepared with {@link createMigration}. + * @param params - An event listener and additional fetch parameters. + * + * @internal This method is one of the step performed by the {@link migrate} method. + */ + private async migrateUpdateDocuments( + migration: Migration, + { + reporter, + ...fetchParams + }: { reporter?: (event: MigrateReporterEvents) => void } & FetchParams = {}, + ): Promise { + let i = 0 + for (const doc of migration._documents) { + reporter?.({ + type: "documents:updating", + data: { + current: ++i, + remaining: migration._documents.length - i, + total: migration._documents.length, + document: doc, + }, + }) + + await this.updateDocument( + doc.document.id!, + // We need to forward again document name and tags to update them + // in case the document already existed during the previous step. + { + ...doc.document, + data: await resolveMigrationDocumentData( + doc.document.data, + migration, + ), + }, + fetchParams, + ) + } + + reporter?.({ + type: "documents:updated", + data: { + updated: migration._documents.length, + }, + }) + } + + /** + * Creates an asset in the Prismic media library. + * + * @param file - The file to upload as an asset. + * @param filename - The filename of the asset. + * @param params - Additional asset data and fetch parameters. + * + * @returns The created asset. + */ + private async createAsset( + file: PostAssetParams["file"] | File, + filename: string, + { + notes, + credits, + alt, + tags, + ...params + }: CreateAssetParams & FetchParams = {}, + ): Promise { + const url = new URL("assets", this.assetAPIEndpoint) + + const formData = new FormData() + formData.append( + "file", + new File([file], filename, { + type: file instanceof File ? file.type : undefined, + }), + ) + + if (notes) { + formData.append("notes", notes) + } + + if (credits) { + formData.append("credits", credits) + } + + if (alt) { + formData.append("alt", alt) + } + + const asset = await this.fetch( + url.toString(), + this.buildAssetAPIQueryParams({ + method: "POST", + body: formData, + params, + }), + ) + + if (tags && tags.length) { + return this.updateAsset(asset.id, { tags }) + } + + return asset + } + + /** + * Updates an asset in the Prismic media library. + * + * @param id - The ID of the asset to update. + * @param params - The asset data to update and additional fetch parameters. + * + * @returns The updated asset. + */ + private async updateAsset( + id: string, + { + notes, + credits, + alt, + filename, + tags, + ...params + }: PatchAssetParams & FetchParams = {}, + ): Promise { + const url = new URL(`assets/${id}`, this.assetAPIEndpoint) + + // Resolve tags if any and create missing ones + if (tags && tags.length) { + tags = await this.resolveAssetTagIDs(tags, { + createTags: true, + ...params, + }) + } + + return this.fetch( + url.toString(), + this.buildAssetAPIQueryParams({ + method: "PATCH", + body: { + notes, + credits, + alt, + filename, + tags, + }, + params, + }), + ) + } + + /** + * Fetches a foreign asset from a URL. + * + * @param url - The URL of the asset to fetch. + * @param params - Additional fetch parameters. + * + * @returns A file representing the fetched asset. + */ + private async fetchForeignAsset( + url: string, + params: FetchParams = {}, + ): Promise { + const requestInit: RequestInitLike = { + ...this.fetchOptions, + ...params.fetchOptions, + headers: { + ...this.fetchOptions?.headers, + ...params.fetchOptions?.headers, + }, + signal: + params.fetchOptions?.signal || + params.signal || + this.fetchOptions?.signal, + } + + const res = await this.fetchFn(url, requestInit) + + if (!res.ok) { + throw new PrismicError("Could not fetch foreign asset", url, undefined) + } + + const blob = await res.blob() + + // Ensure a correct content type is attached to the blob. + return new File([blob], "", { + type: res.headers.get("content-type") || undefined, + }) + } + + /** + * {@link resolveAssetTagIDs} rate limiter. + */ + private _resolveAssetTagIDsLimit = pLimit() + + /** + * Resolves asset tag IDs from tag names. + * + * @param tagNames - An array of tag names to resolve. + * @param params - Whether or not missing tags should be created and + * additional fetch parameters. + * + * @returns An array of resolved tag IDs. + */ + private async resolveAssetTagIDs( + tagNames: string[] = [], + { createTags, ...params }: { createTags?: boolean } & FetchParams = {}, + ): Promise { + return this._resolveAssetTagIDsLimit(async () => { + const existingTags = await this.getAssetTags(params) + const existingTagMap: Record = {} + for (const tag of existingTags) { + existingTagMap[tag.name] = tag + } + + const resolvedTagIDs = [] + for (const tagName of tagNames) { + // Tag does not exists yet, we create it if `createTags` is set + if (!existingTagMap[tagName] && createTags) { + existingTagMap[tagName] = await this.createAssetTag(tagName, params) + } + + // Add tag if found + if (existingTagMap[tagName]) { + resolvedTagIDs.push(existingTagMap[tagName].id) + } + } + + return resolvedTagIDs + }) + } + + /** + * Creates a tag in the Asset API. + * + * @remarks + * Tags should be at least 3 characters long and 20 characters at most. + * + * @param name - The name of the tag to create. + * @param params - Additional fetch parameters. + * + * @returns The created tag. + */ + private async createAssetTag( + name: string, + params?: FetchParams, + ): Promise { + const url = new URL("tags", this.assetAPIEndpoint) + + return this.fetch( + url.toString(), + this.buildAssetAPIQueryParams({ + method: "POST", + body: { name }, + params, + }), + ) + } + + /** + * Queries existing tags from the Asset API. + * + * @param params - Additional fetch parameters. + * + * @returns An array of existing tags. + */ + private async getAssetTags(params?: FetchParams): Promise { + const url = new URL("tags", this.assetAPIEndpoint) + + const { items } = await this.fetch( + url.toString(), + this.buildAssetAPIQueryParams({ params }), + ) + + return items + } + + /** + * Creates a document in the repository's migration release. + * + * @typeParam TType - Type of Prismic documents to create. + * + * @param document - The document to create. + * @param documentTitle - The title of the document to create which will be + * displayed in the editor. + * @param params - Document master language document ID and additional fetch + * parameters. + * + * @returns The ID of the created document. + * + * @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference} + */ + private async createDocument( + document: PendingPrismicDocument>, + documentTitle: string, + { + masterLanguageDocumentID, + ...params + }: { masterLanguageDocumentID?: string } & FetchParams = {}, + ): Promise<{ id: string }> { + const url = new URL("documents", this.migrationAPIEndpoint) + + const result = await this.fetch( + url.toString(), + this.buildMigrationAPIQueryParams({ + method: "POST", + body: { + title: documentTitle, + type: document.type, + uid: document.uid || undefined, + lang: document.lang, + alternate_language_id: masterLanguageDocumentID, + tags: document.tags, + data: document.data, + }, + params, + }), + ) + + return { id: result.id } + } + + /** + * Updates an existing document in the repository's migration release. + * + * @typeParam TType - Type of Prismic documents to update. + * + * @param id - The ID of the document to update. + * @param document - The document content to update. + * @param params - Additional fetch parameters. + * + * @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference} + */ + private async updateDocument( + id: string, + document: MigrationDocument> & { + documentTitle?: string + }, + params?: FetchParams, + ): Promise { + const url = new URL(`documents/${id}`, this.migrationAPIEndpoint) + + await this.fetch( + url.toString(), + this.buildMigrationAPIQueryParams({ + method: "PUT", + body: { + title: document.documentTitle, + uid: document.uid || undefined, + tags: document.tags, + data: document.data, + }, + params, + }), + ) + } + + /** + * Builds fetch parameters for the Asset API. + * + * @typeParam TBody - Type of the body to send in the fetch request. + * + * @param params - Method, body, and additional fetch parameters. + * + * @returns An object that can be fetched to interact with the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ + private buildAssetAPIQueryParams>({ + method, + body, + params, + }: { + method?: string + body?: TBody + params?: FetchParams + }): FetchParams { + const headers: Record = { + ...params?.fetchOptions?.headers, + authorization: `Bearer ${this.writeToken}`, + repository: this.repositoryName, + } + + let _body: FormData | string | undefined + if (body instanceof FormData) { + _body = body + } else if (body) { + _body = JSON.stringify(body) + headers["content-type"] = "application/json" + } + + return { + ...params, + fetchOptions: { + ...params?.fetchOptions, + method, + body: _body, + headers, + }, + } + } + + /** + * Builds fetch parameters for the Migration API. + * + * @typeParam TBody - Type of the body to send in the fetch request. + * + * @param params - Method, body, and additional fetch options. + * + * @returns An object that can be fetched to interact with the Migration API. + * + * @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference} + */ + private buildMigrationAPIQueryParams< + TBody extends PostDocumentParams | PutDocumentParams, + >({ + method, + body, + params, + }: { + method?: string + body: TBody + params?: FetchParams + }): FetchParams { + return { + ...params, + fetchOptions: { + ...params?.fetchOptions, + method, + body: JSON.stringify(body), + headers: { + ...params?.fetchOptions?.headers, + "content-type": "application/json", + repository: this.repositoryName, + authorization: `Bearer ${this.writeToken}`, + "x-api-key": this.migrationAPIKey, + }, + }, + } + } +} diff --git a/src/createClient.ts b/src/createClient.ts index 070e134e..a3c16f9c 100644 --- a/src/createClient.ts +++ b/src/createClient.ts @@ -1,374 +1,7 @@ -import { appendFilters } from "./lib/appendFilters" -import { castThunk } from "./lib/castThunk" -import { devMsg } from "./lib/devMsg" -import { everyTagFilter } from "./lib/everyTagFilter" -import { findMasterRef } from "./lib/findMasterRef" -import { findRefByID } from "./lib/findRefByID" -import { findRefByLabel } from "./lib/findRefByLabel" -import { getPreviewCookie } from "./lib/getPreviewCookie" -import { minifyGraphQLQuery } from "./lib/minifyGraphQLQuery" -import { someTagsFilter } from "./lib/someTagsFilter" -import { typeFilter } from "./lib/typeFilter" - -import type { Query } from "./types/api/query" -import type { Ref } from "./types/api/ref" -import type { Form, Repository } from "./types/api/repository" import type { PrismicDocument } from "./types/value/document" -import { ForbiddenError } from "./errors/ForbiddenError" -import { NotFoundError } from "./errors/NotFoundError" -import { ParsingError } from "./errors/ParsingError" -import { PreviewTokenExpiredError } from "./errors/PreviewTokenExpired" -import { PrismicError } from "./errors/PrismicError" -import { RefExpiredError } from "./errors/RefExpiredError" -import { RefNotFoundError } from "./errors/RefNotFoundError" -import { RepositoryNotFoundError } from "./errors/RepositoryNotFoundError" - -import type { LinkResolverFunction } from "./helpers/asLink" -import { asLink } from "./helpers/asLink" - -import type { BuildQueryURLArgs } from "./buildQueryURL" -import { buildQueryURL } from "./buildQueryURL" -import { filter } from "./filter" -import { getRepositoryEndpoint } from "./getRepositoryEndpoint" -import { getRepositoryName } from "./getRepositoryName" -import { isRepositoryEndpoint } from "./isRepositoryEndpoint" - -/** - * The largest page size allowed by the Prismic REST API V2. This value is used - * to minimize the number of requests required to query content. - */ -const MAX_PAGE_SIZE = 100 - -/** - * The number of milliseconds in which repository metadata is considered valid. - * A ref can be invalidated quickly depending on how frequently content is - * updated in the Prismic repository. As such, repository's metadata can only be - * considered valid for a short amount of time. - */ -export const REPOSITORY_CACHE_TTL = 5000 - -/** - * The number of milliseconds in which a multi-page `getAll` (e.g. `getAll`, - * `getAllByType`, `getAllByTag`) will wait between individual page requests. - * - * This is done to ensure API performance is sustainable and reduces the chance - * of a failed API request due to overloading. - */ -export const GET_ALL_QUERY_DELAY = 500 - -/** - * The default number of milliseconds to wait before retrying a rate-limited - * `fetch()` request (429 response code). The default value is only used if the - * response does not include a `retry-after` header. - * - * The API allows up to 200 requests per second. - */ -const DEFUALT_RETRY_AFTER_MS = 1000 - -/** - * Extracts one or more Prismic document types that match a given Prismic - * document type. If no matches are found, no extraction is performed and the - * union of all provided Prismic document types are returned. - * - * @typeParam TDocuments - Prismic document types from which to extract. - * @typeParam TDocumentType - Type(s) to match `TDocuments` against. - */ -type ExtractDocumentType< - TDocuments extends PrismicDocument, - TDocumentType extends TDocuments["type"], -> = - Extract extends never - ? TDocuments - : Extract - -/** - * A universal API to make network requests. A subset of the `fetch()` API. - * - * {@link https://developer.mozilla.org/en-US/docs/Web/API/fetch} - */ -export type FetchLike = ( - input: string, - init?: RequestInitLike, -) => Promise - -/** - * An object that allows you to abort a `fetch()` request if needed via an - * `AbortController` object - * - * {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal} - */ -// `any` is used often here to ensure this type is universally valid among -// different AbortSignal implementations. The types of each property are not -// important to validate since it is blindly passed to a given `fetch()` -// function. -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type AbortSignalLike = any - -/** - * A subset of RequestInit properties to configure a `fetch()` request. - */ -// Only options relevant to the client are included. Extending from the full -// RequestInit would cause issues, such as accepting Header objects. -// -// An interface is used to allow other libraries to augment the type with -// environment-specific types. -export interface RequestInitLike extends Pick { - /** - * An object literal to set the `fetch()` request's headers. - */ - headers?: Record - - /** - * An AbortSignal to set the `fetch()` request's signal. - * - * See: - * [https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) - */ - // NOTE: `AbortSignalLike` is `any`! It is left as `AbortSignalLike` - // for backwards compatibility (the type is exported) and to signal to - // other readers that this should be an AbortSignal-like object. - signal?: AbortSignalLike -} - -/** - * The minimum required properties from Response. - */ -export interface ResponseLike { - status: number - headers: HeadersLike - // eslint-disable-next-line @typescript-eslint/no-explicit-any - json(): Promise -} - -/** - * The minimum required properties from Headers. - */ -export interface HeadersLike { - get(name: string): string | null -} - -/** - * The minimum required properties to treat as an HTTP Request for automatic - * Prismic preview support. - */ -export type HttpRequestLike = - | /** - * Web API Request - * - * @see http://developer.mozilla.org/en-US/docs/Web/API/Request - */ - { - headers?: { - get(name: string): string | null - } - url?: string - } - - /** - * Express-style Request - */ - | { - headers?: { - cookie?: string - } - query?: Record - } - -/** - * Modes for client ref management. - */ -enum RefStateMode { - /** - * Use the repository's master ref. - */ - Master = "Master", - - /** - * Use a given Release identified by its ID. - */ - ReleaseID = "ReleaseID", - - /** - * Use a given Release identified by its label. - */ - ReleaseLabel = "ReleaseLabel", - - /** - * Use a given ref. - */ - Manual = "Manual", -} - -/** - * An object containing stateful information about a client's ref strategy. - */ -type RefState = { - /** - * Determines if automatic preview support is enabled. - */ - autoPreviewsEnabled: boolean - - /** - * An optional HTTP server request object used during previews if automatic - * previews are enabled. - */ - httpRequest?: HttpRequestLike -} & ( - | { - mode: RefStateMode.Master - } - | { - mode: RefStateMode.ReleaseID - releaseID: string - } - | { - mode: RefStateMode.ReleaseLabel - releaseLabel: string - } - | { - mode: RefStateMode.Manual - ref: RefStringOrThunk - } -) - -/** - * A ref or a function that returns a ref. If a static ref is known, one can be - * given. If the ref must be fetched on-demand, a function can be provided. This - * function can optionally be asynchronous. - */ -type RefStringOrThunk = - | string - | (() => string | undefined | Promise) - -/** - * Configuration for clients that determine how content is queried. - */ -export type ClientConfig = { - /** - * The secure token for accessing the Prismic repository. This is only - * required if the repository is set to private. - */ - accessToken?: string - - /** - * A string representing a version of the Prismic repository's content. This - * may point to the latest version (called the "master ref"), or a preview - * with draft content. - */ - ref?: RefStringOrThunk - - /** - * A list of route resolver objects that define how a document's `url` - * property is resolved. - * - * {@link https://prismic.io/docs/route-resolver} - */ - routes?: NonNullable - - /** - * The `brokenRoute` option allows you to define the route populated in the - * `url` property for broken link or content relationship fields. A broken - * link is a link or content relationship field whose linked document has been - * unpublished or deleted. - * - * {@link https://prismic.io/docs/route-resolver} - */ - brokenRoute?: NonNullable - - /** - * Default parameters that will be sent with each query. These parameters can - * be overridden on each query if needed. - */ - defaultParams?: Omit< - BuildQueryURLArgs, - "ref" | "integrationFieldsRef" | "accessToken" | "routes" | "brokenRoute" - > - - /** - * The function used to make network requests to the Prismic REST API. In - * environments where a global `fetch` function does not exist, such as - * Node.js, this function must be provided. - */ - fetch?: FetchLike - - /** - * Options provided to the client's `fetch()` on all network requests. These - * options will be merged with internally required options. They can also be - * overriden on a per-query basis using the query's `fetchOptions` parameter. - */ - fetchOptions?: RequestInitLike -} - -/** - * Parameters for any client method that use `fetch()`. - */ -type FetchParams = { - /** - * Options provided to the client's `fetch()` on all network requests. These - * options will be merged with internally required options. They can also be - * overriden on a per-query basis using the query's `fetchOptions` parameter. - */ - fetchOptions?: RequestInitLike - - /** - * An `AbortSignal` provided by an `AbortController`. This allows the network - * request to be cancelled if necessary. - * - * @deprecated Move the `signal` parameter into `fetchOptions.signal`: - * - * @see \ - */ - signal?: AbortSignalLike -} - -/** - * Parameters specific to client methods that fetch all documents. These methods - * start with `getAll` (for example, `getAllByType`). - */ -type GetAllParams = { - /** - * Limit the number of documents queried. If a number is not provided, there - * will be no limit and all matching documents will be returned. - */ - limit?: number -} - -/** - * Arguments to determine how the URL for a preview session is resolved. - */ -type ResolvePreviewArgs = { - /** - * A function that maps a Prismic document to a URL within your app. - */ - linkResolver?: LinkResolverFunction - - /** - * A fallback URL if the link resolver does not return a value. - */ - defaultURL: string - - /** - * The preview token (also known as a ref) that will be used to query preview - * content from the Prismic repository. - */ - previewToken?: string - - /** - * The previewed document that will be used to determine the destination URL. - */ - documentID?: string -} - -/** - * The result of a `fetch()` job. - */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type FetchJobResult = { - status: number - headers: HeadersLike - json: TJSON -} +import type { ClientConfig } from "./Client" +import { Client } from "./Client" /** * Type definitions for the `createClient()` function. May be augmented by @@ -393,8 +26,7 @@ export interface CreateClient { * createClient("https://qwerty.cdn.prismic.io/api/v2") * ``` * - * @typeParam TDocuments - A map of Prismic document type IDs mapped to their - * TypeScript type. + * @typeParam TDocuments - A union of Prismic document types for the repository. * * @param repositoryNameOrEndpoint - The Prismic repository name or full Rest * API V2 endpoint for the repository. @@ -407,1563 +39,3 @@ export const createClient: CreateClient = ( repositoryNameOrEndpoint: string, options?: ClientConfig, ) => new Client(repositoryNameOrEndpoint, options) - -/** - * A client that allows querying content from a Prismic repository. - * - * If used in an environment where a global `fetch` function is unavailable, - * such as Node.js, the `fetch` option must be provided as part of the `options` - * parameter. - * - * @typeParam TDocuments - Document types that are registered for the Prismic - * repository. Query methods will automatically be typed based on this type. - */ -export class Client { - /** - * The Prismic REST API V2 endpoint for the repository (use - * `prismic.getRepositoryEndpoint` for the default endpoint). - */ - endpoint: string - - /** - * The secure token for accessing the API (only needed if your repository is - * set to private). - * - * {@link https://user-guides.prismic.io/en/articles/1036153-generating-an-access-token} - */ - accessToken?: string - - /** - * A list of route resolver objects that define how a document's `url` field - * is resolved. - * - * {@link https://prismic.io/docs/route-resolver} - */ - routes?: NonNullable - - /** - * The `brokenRoute` option allows you to define the route populated in the - * `url` property for broken link or content relationship fields. A broken - * link is a link or content relationship field whose linked document has been - * unpublished or deleted. - * - * {@link https://prismic.io/docs/route-resolver} - */ - brokenRoute?: NonNullable - - /** - * The function used to make network requests to the Prismic REST API. In - * environments where a global `fetch` function does not exist, such as - * Node.js, this function must be provided. - */ - fetchFn: FetchLike - - fetchOptions?: RequestInitLike - - /** - * Default parameters that will be sent with each query. These parameters can - * be overridden on each query if needed. - */ - defaultParams?: Omit< - BuildQueryURLArgs, - "ref" | "integrationFieldsRef" | "accessToken" | "routes" - > - - /** - * The client's ref mode state. This determines which ref is used during - * queries. - */ - private refState: RefState = { - mode: RefStateMode.Master, - autoPreviewsEnabled: true, - } - - /** - * Cached repository value. - */ - private cachedRepository: Repository | undefined - - /** - * Timestamp at which the cached repository data is considered stale. - */ - private cachedRepositoryExpiration = 0 - - /** - * Active `fetch()` jobs keyed by URL and AbortSignal (if it exists). - */ - private fetchJobs: Record< - string, - Map> - > = {} - - /** - * Creates a Prismic client that can be used to query a repository. - * - * If used in an environment where a global `fetch` function is unavailable, - * such as Node.js, the `fetch` option must be provided as part of the - * `options` parameter. - * - * @param repositoryNameOrEndpoint - The Prismic repository name or full Rest - * API V2 endpoint for the repository. - * @param options - Configuration that determines how content will be queried - * from the Prismic repository. - * - * @returns A client that can query content from the repository. - */ - constructor(repositoryNameOrEndpoint: string, options: ClientConfig = {}) { - if (isRepositoryEndpoint(repositoryNameOrEndpoint)) { - if (process.env.NODE_ENV === "development") { - // Matches non-API v2 `.prismic.io` endpoints, see: https://regex101.com/r/xRsavu/1 - if (/\.prismic\.io\/(?!api\/v2\/?)/i.test(repositoryNameOrEndpoint)) { - throw new PrismicError( - "@prismicio/client only supports Prismic Rest API V2. Please provide only the repository name to the first createClient() parameter or use the getRepositoryEndpoint() helper to generate a valid Rest API V2 endpoint URL.", - undefined, - undefined, - ) - } - - const hostname = new URL( - repositoryNameOrEndpoint, - ).hostname.toLowerCase() - - // Matches non-.cdn `.prismic.io` endpoints - if ( - hostname.endsWith(".prismic.io") && - !hostname.endsWith(".cdn.prismic.io") - ) { - const repositoryName = getRepositoryName(repositoryNameOrEndpoint) - const dotCDNEndpoint = getRepositoryEndpoint(repositoryName) - console.warn( - `[@prismicio/client] A non-.cdn endpoint was provided to create a client with (\`${repositoryNameOrEndpoint}\`). Non-.cdn endpoints can have unexpected side-effects and cause performance issues when querying Prismic. Please convert it to the \`.cdn\` alternative (\`${dotCDNEndpoint}\`) or use the repository name directly instead (\`${repositoryName}\`). For more details, see ${devMsg( - "endpoint-must-use-cdn", - )}`, - ) - } - } - - this.endpoint = repositoryNameOrEndpoint - } else { - this.endpoint = getRepositoryEndpoint(repositoryNameOrEndpoint) - } - - this.accessToken = options.accessToken - this.routes = options.routes - this.brokenRoute = options.brokenRoute - this.fetchOptions = options.fetchOptions - this.defaultParams = options.defaultParams - - if (options.ref) { - this.queryContentFromRef(options.ref) - } - - if (typeof options.fetch === "function") { - this.fetchFn = options.fetch - } else if (typeof globalThis.fetch === "function") { - this.fetchFn = globalThis.fetch as FetchLike - } else { - throw new PrismicError( - "A valid fetch implementation was not provided. In environments where fetch is not available (including Node.js), a fetch implementation must be provided via a polyfill or the `fetch` option.", - undefined, - undefined, - ) - } - - // If the global fetch function is used, we must bind it to the global scope. - if (this.fetchFn === globalThis.fetch) { - this.fetchFn = this.fetchFn.bind(globalThis) - } - - this.graphQLFetch = this.graphQLFetch.bind(this) - } - - /** - * Enables the client to automatically query content from a preview session if - * one is active in browser environments. This is enabled by default in the - * browser. - * - * For server environments, use `enableAutoPreviewsFromReq`. - * - * @example - * - * ```ts - * client.enableAutoPreviews() - * ``` - * - * @see enableAutoPreviewsFromReq - */ - enableAutoPreviews(): void { - this.refState.autoPreviewsEnabled = true - } - - /** - * Enables the client to automatically query content from a preview session if - * one is active in server environments. This is disabled by default on the - * server. - * - * For browser environments, use `enableAutoPreviews`. - * - * @example - * - * ```ts - * // In an express app - * app.get("/", function (req, res) { - * client.enableAutoPreviewsFromReq(req) - * }) - * ``` - * - * @param req - An HTTP server request object containing the request's - * cookies. - */ - enableAutoPreviewsFromReq(req: R): void { - this.refState.httpRequest = req - this.refState.autoPreviewsEnabled = true - } - - /** - * Disables the client from automatically querying content from a preview - * session if one is active. - * - * Automatic preview content querying is enabled by default unless this method - * is called. - * - * @example - * - * ```ts - * client.disableAutoPreviews() - * ``` - */ - disableAutoPreviews(): void { - this.refState.autoPreviewsEnabled = false - } - - /** - * Queries content from the Prismic repository. - * - * @example - * - * ```ts - * const response = await client.get() - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param params - Parameters to filter, sort, and paginate results. - * - * @returns A paginated response containing the result of the query. - */ - async get( - params?: Partial & FetchParams, - ): Promise> { - const url = await this.buildQueryURL(params) - - return await this.fetch>(url, params) - } - - /** - * Queries content from the Prismic repository and returns only the first - * result, if any. - * - * @example - * - * ```ts - * const document = await client.getFirst() - * ``` - * - * @typeParam TDocument - Type of the Prismic document returned. - * - * @param params - Parameters to filter, sort, and paginate results. @returns - * The first result of the query, if any. - */ - async getFirst( - params?: Partial & FetchParams, - ): Promise { - const actualParams = { ...params } - if (!(params && params.page) && !params?.pageSize) { - actualParams.pageSize = this.defaultParams?.pageSize ?? 1 - } - const url = await this.buildQueryURL(actualParams) - const result = await this.fetch>(url, params) - - const firstResult = result.results[0] - - if (firstResult) { - return firstResult - } - - throw new NotFoundError("No documents were returned", url, undefined) - } - - /** - * **IMPORTANT**: Avoid using `dangerouslyGetAll` as it may be slower and - * require more resources than other methods. Prefer using other methods that - * filter by filters such as `getAllByType`. - * - * Queries content from the Prismic repository and returns all matching - * content. If no filters are provided, all documents will be fetched. - * - * This method may make multiple network requests to query all matching - * content. - * - * @example - * - * ```ts - * const response = await client.dangerouslyGetAll() - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param params - Parameters to filter, sort, and paginate results. - * - * @returns A list of documents matching the query. - */ - async dangerouslyGetAll( - params: Partial> & - GetAllParams & - FetchParams = {}, - ): Promise { - const { limit = Infinity, ...actualParams } = params - const resolvedParams = { - ...actualParams, - pageSize: Math.min( - limit, - actualParams.pageSize || this.defaultParams?.pageSize || MAX_PAGE_SIZE, - ), - } - - const documents: TDocument[] = [] - let latestResult: Query | undefined - - while ( - (!latestResult || latestResult.next_page) && - documents.length < limit - ) { - const page = latestResult ? latestResult.page + 1 : undefined - - latestResult = await this.get({ ...resolvedParams, page }) - documents.push(...latestResult.results) - - if (latestResult.next_page) { - await new Promise((res) => setTimeout(res, GET_ALL_QUERY_DELAY)) - } - } - - return documents.slice(0, limit) - } - - /** - * Queries a document from the Prismic repository with a specific ID. - * - * @remarks - * A document's UID is different from its ID. An ID is automatically generated - * for all documents and is made available on its `id` property. A UID is - * provided in the Prismic editor and is unique among all documents of its - * custom type. - * - * @example - * - * ```ts - * const document = await client.getByID("WW4bKScAAMAqmluX") - * ``` - * - * @typeParam TDocument- Type of the Prismic document returned. - * - * @param id - ID of the document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns The document with an ID matching the `id` parameter, if a matching - * document exists. - */ - async getByID( - id: string, - params?: Partial & FetchParams, - ): Promise { - return await this.getFirst( - appendFilters(params, filter.at("document.id", id)), - ) - } - - /** - * Queries documents from the Prismic repository with specific IDs. - * - * @remarks - * A document's UID is different from its ID. An ID is automatically generated - * for all documents and is made available on its `id` property. A UID is - * provided in the Prismic editor and is unique among all documents of its - * custom type. - * - * @example - * - * ```ts - * const response = await client.getByIDs([ - * "WW4bKScAAMAqmluX", - * "U1kTRgEAAC8A5ldS", - * ]) - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param ids - A list of document IDs. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A paginated response containing documents with IDs matching the - * `ids` parameter. - */ - async getByIDs( - ids: string[], - params?: Partial & FetchParams, - ): Promise> { - return await this.get( - appendFilters(params, filter.in("document.id", ids)), - ) - } - - /** - * Queries all documents from the Prismic repository with specific IDs. - * - * This method may make multiple network requests to query all matching - * content. - * - * @remarks - * A document's UID is different from its ID. An ID is automatically generated - * for all documents and is made available on its `id` property. A UID is - * provided in the Prismic editor and is unique among all documents of its - * custom type. - * - * @example - * - * ```ts - * const response = await client.getAllByIDs([ - * "WW4bKScAAMAqmluX", - * "U1kTRgEAAC8A5ldS", - * ]) - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param ids - A list of document IDs. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A list of documents with IDs matching the `ids` parameter. - */ - async getAllByIDs( - ids: string[], - params?: Partial & GetAllParams & FetchParams, - ): Promise { - return await this.dangerouslyGetAll( - appendFilters(params, filter.in("document.id", ids)), - ) - } - - /** - * Queries a document from the Prismic repository with a specific UID and - * custom type. - * - * @remarks - * A document's UID is different from its ID. An ID is automatically generated - * for all documents and is made available on its `id` property. A UID is - * provided in the Prismic editor and is unique among all documents of its - * custom type. - * - * @example - * - * ```ts - * const document = await client.getByUID("blog_post", "my-first-post") - * ``` - * - * @typeParam TDocument - Type of the Prismic document returned. - * - * @param documentType - The API ID of the document's custom type. - * @param uid - UID of the document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns The document with a UID matching the `uid` parameter, if a - * matching document exists. - */ - async getByUID< - TDocument extends TDocuments, - TDocumentType extends TDocument["type"] = TDocument["type"], - >( - documentType: TDocumentType, - uid: string, - params?: Partial & FetchParams, - ): Promise> { - return await this.getFirst>( - appendFilters(params, [ - typeFilter(documentType), - filter.at(`my.${documentType}.uid`, uid), - ]), - ) - } - - /** - * Queries document from the Prismic repository with specific UIDs and Custom - * Type. - * - * @remarks - * A document's UID is different from its ID. An ID is automatically generated - * for all documents and is made available on its `id` property. A UID is - * provided in the Prismic editor and is unique among all documents of its - * custom type. - * - * @example - * - * ```ts - * const document = await client.getByUIDs("blog_post", [ - * "my-first-post", - * "my-second-post", - * ]) - * ``` - * - * @typeParam TDocument - Type of the Prismic document returned. - * - * @param documentType - The API ID of the document's custom type. - * @param uids - A list of document UIDs. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A paginated response containing documents with UIDs matching the - * `uids` parameter. - */ - async getByUIDs< - TDocument extends TDocuments, - TDocumentType extends TDocument["type"] = TDocument["type"], - >( - documentType: TDocumentType, - uids: string[], - params?: Partial & FetchParams, - ): Promise>> { - return await this.get>( - appendFilters(params, [ - typeFilter(documentType), - filter.in(`my.${documentType}.uid`, uids), - ]), - ) - } - - /** - * Queries all documents from the Prismic repository with specific UIDs and - * custom type. - * - * This method may make multiple network requests to query all matching - * content. - * - * @remarks - * A document's UID is different from its ID. An ID is automatically generated - * for all documents and is made available on its `id` property. A UID is - * provided in the Prismic editor and is unique among all documents of its - * custom type. - * - * @example - * - * ```ts - * const response = await client.getAllByUIDs([ - * "my-first-post", - * "my-second-post", - * ]) - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param documentType - The API ID of the document's custom type. - * @param uids - A list of document UIDs. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A list of documents with UIDs matching the `uids` parameter. - */ - async getAllByUIDs< - TDocument extends TDocuments, - TDocumentType extends TDocument["type"] = TDocument["type"], - >( - documentType: TDocumentType, - uids: string[], - params?: Partial & GetAllParams & FetchParams, - ): Promise[]> { - return await this.dangerouslyGetAll< - ExtractDocumentType - >( - appendFilters(params, [ - typeFilter(documentType), - filter.in(`my.${documentType}.uid`, uids), - ]), - ) - } - - /** - * Queries a singleton document from the Prismic repository for a specific - * custom type. - * - * @remarks - * A singleton document is one that is configured in Prismic to only allow one - * instance. For example, a repository may be configured to contain just one - * Settings document. This is in contrast to a repeatable custom type which - * allows multiple instances of itself. - * - * @example - * - * ```ts - * const document = await client.getSingle("settings") - * ``` - * - * @typeParam TDocument - Type of the Prismic document returned. - * - * @param documentType - The API ID of the singleton custom type. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns The singleton document for the custom type, if a matching document - * exists. - */ - async getSingle< - TDocument extends TDocuments, - TDocumentType extends TDocument["type"] = TDocument["type"], - >( - documentType: TDocumentType, - params?: Partial & FetchParams, - ): Promise> { - return await this.getFirst>( - appendFilters(params, typeFilter(documentType)), - ) - } - - /** - * Queries documents from the Prismic repository for a specific custom type. - * - * Use `getAllByType` instead if you need to query all documents for a - * specific custom type. - * - * @example - * - * ```ts - * const response = await client.getByType("blog_post") - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param documentType - The API ID of the custom type. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A paginated response containing documents of the custom type. - */ - async getByType< - TDocument extends TDocuments, - TDocumentType extends TDocument["type"] = TDocument["type"], - >( - documentType: TDocumentType, - params?: Partial & FetchParams, - ): Promise>> { - return await this.get>( - appendFilters(params, typeFilter(documentType)), - ) - } - - /** - * Queries all documents from the Prismic repository for a specific Custom - * Type. - * - * This method may make multiple network requests to query all matching - * content. - * - * @example - * - * ```ts - * const response = await client.getByType("blog_post") - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param documentType - The API ID of the custom type. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A list of all documents of the custom type. - */ - async getAllByType< - TDocument extends TDocuments, - TDocumentType extends TDocument["type"] = TDocument["type"], - >( - documentType: TDocumentType, - params?: Partial> & - GetAllParams & - FetchParams, - ): Promise[]> { - return await this.dangerouslyGetAll< - ExtractDocumentType - >(appendFilters(params, typeFilter(documentType))) - } - - /** - * Queries documents from the Prismic repository with a specific tag. - * - * Use `getAllByTag` instead if you need to query all documents with a - * specific tag. - * - * @example - * - * ```ts - * const response = await client.getByTag("food") - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param tag - The tag that must be included on a document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A paginated response containing documents with the tag. - */ - async getByTag( - tag: string, - params?: Partial & FetchParams, - ): Promise> { - return await this.get(appendFilters(params, someTagsFilter(tag))) - } - - /** - * Queries all documents from the Prismic repository with a specific tag. - * - * This method may make multiple network requests to query all matching - * content. - * - * @example - * - * ```ts - * const response = await client.getAllByTag("food") - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param tag - The tag that must be included on a document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A list of all documents with the tag. - */ - async getAllByTag( - tag: string, - params?: Partial> & - GetAllParams & - FetchParams, - ): Promise { - return await this.dangerouslyGetAll( - appendFilters(params, someTagsFilter(tag)), - ) - } - - /** - * Queries documents from the Prismic repository with specific tags. A - * document must be tagged with all of the queried tags to be included. - * - * @example - * - * ```ts - * const response = await client.getByEveryTag(["food", "fruit"]) - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param tags - A list of tags that must be included on a document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A paginated response containing documents with the tags. - */ - async getByEveryTag( - tags: string[], - params?: Partial & FetchParams, - ): Promise> { - return await this.get( - appendFilters(params, everyTagFilter(tags)), - ) - } - - /** - * Queries documents from the Prismic repository with specific tags. A - * document must be tagged with all of the queried tags to be included. - * - * This method may make multiple network requests to query all matching - * content. - * - * @example - * - * ```ts - * const response = await client.getAllByEveryTag(["food", "fruit"]) - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param tags - A list of tags that must be included on a document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A list of all documents with the tags. - */ - async getAllByEveryTag( - tags: string[], - params?: Partial> & - GetAllParams & - FetchParams, - ): Promise { - return await this.dangerouslyGetAll( - appendFilters(params, everyTagFilter(tags)), - ) - } - - /** - * Queries documents from the Prismic repository with specific tags. A - * document must be tagged with at least one of the queried tags to be - * included. - * - * @example - * - * ```ts - * const response = await client.getByEveryTag(["food", "fruit"]) - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param tags - A list of tags that must be included on a document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A paginated response containing documents with at least one of the - * tags. - */ - async getBySomeTags( - tags: string[], - params?: Partial & FetchParams, - ): Promise> { - return await this.get( - appendFilters(params, someTagsFilter(tags)), - ) - } - - /** - * Queries documents from the Prismic repository with specific tags. A - * document must be tagged with at least one of the queried tags to be - * included. - * - * This method may make multiple network requests to query all matching - * content. - * - * @example - * - * ```ts - * const response = await client.getAllBySomeTags(["food", "fruit"]) - * ``` - * - * @typeParam TDocument - Type of Prismic documents returned. - * - * @param tags - A list of tags that must be included on a document. - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A list of all documents with at least one of the tags. - */ - async getAllBySomeTags( - tags: string[], - params?: Partial> & - GetAllParams & - FetchParams, - ): Promise { - return await this.dangerouslyGetAll( - appendFilters(params, someTagsFilter(tags)), - ) - } - - /** - * Returns metadata about the Prismic repository, such as its refs, releases, - * and custom types. - * - * @returns Repository metadata. - */ - async getRepository(params?: FetchParams): Promise { - // TODO: Restore when Authorization header support works in browsers with CORS. - // return await this.fetch(this.endpoint); - - const url = new URL(this.endpoint) - - if (this.accessToken) { - url.searchParams.set("access_token", this.accessToken) - } - - return await this.fetch(url.toString(), params) - } - - /** - * Returns a list of all refs for the Prismic repository. - * - * Refs are used to identify which version of the repository's content should - * be queried. All repositories will have at least one ref pointing to the - * latest published content called the "master ref". - * - * @returns A list of all refs for the Prismic repository. - */ - async getRefs(params?: FetchParams): Promise { - const repository = await this.getRepository(params) - - return repository.refs - } - - /** - * Returns a ref for the Prismic repository with a matching ID. - * - * @param id - ID of the ref. - * - * @returns The ref with a matching ID, if it exists. - */ - async getRefByID(id: string, params?: FetchParams): Promise { - const refs = await this.getRefs(params) - - return findRefByID(refs, id) - } - - /** - * Returns a ref for the Prismic repository with a matching label. - * - * @param label - Label of the ref. - * - * @returns The ref with a matching label, if it exists. - */ - async getRefByLabel(label: string, params?: FetchParams): Promise { - const refs = await this.getRefs(params) - - return findRefByLabel(refs, label) - } - - /** - * Returns the master ref for the Prismic repository. The master ref points to - * the repository's latest published content. - * - * @returns The repository's master ref. - */ - async getMasterRef(params?: FetchParams): Promise { - const refs = await this.getRefs(params) - - return findMasterRef(refs) - } - - /** - * Returns a list of all Releases for the Prismic repository. Releases are - * used to group content changes before publishing. - * - * @returns A list of all Releases for the Prismic repository. - */ - async getReleases(params?: FetchParams): Promise { - const refs = await this.getRefs(params) - - return refs.filter((ref) => !ref.isMasterRef) - } - - /** - * Returns a Release for the Prismic repository with a matching ID. - * - * @param id - ID of the Release. - * - * @returns The Release with a matching ID, if it exists. - */ - async getReleaseByID(id: string, params?: FetchParams): Promise { - const releases = await this.getReleases(params) - - return findRefByID(releases, id) - } - - /** - * Returns a Release for the Prismic repository with a matching label. - * - * @param label - Label of the ref. - * - * @returns The ref with a matching label, if it exists. - */ - async getReleaseByLabel(label: string, params?: FetchParams): Promise { - const releases = await this.getReleases(params) - - return findRefByLabel(releases, label) - } - - /** - * Returns a list of all tags used in the Prismic repository. - * - * @returns A list of all tags used in the repository. - */ - async getTags(params?: FetchParams): Promise { - try { - const tagsForm = await this.getCachedRepositoryForm("tags", params) - - const url = new URL(tagsForm.action) - - if (this.accessToken) { - url.searchParams.set("access_token", this.accessToken) - } - - return await this.fetch(url.toString(), params) - } catch { - const repository = await this.getRepository(params) - - return repository.tags - } - } - - /** - * Builds a URL used to query content from the Prismic repository. - * - * @param params - Parameters to filter, sort, and paginate the results. - * - * @returns A URL string that can be requested to query content. - */ - async buildQueryURL({ - signal, - fetchOptions, - ...params - }: Partial & FetchParams = {}): Promise { - const ref = - params.ref || (await this.getResolvedRefString({ signal, fetchOptions })) - const integrationFieldsRef = - params.integrationFieldsRef || - (await this.getCachedRepository({ signal, fetchOptions })) - .integrationFieldsRef || - undefined - - return buildQueryURL(this.endpoint, { - ...this.defaultParams, - ...params, - ref, - integrationFieldsRef, - routes: params.routes || this.routes, - brokenRoute: params.brokenRoute || this.brokenRoute, - accessToken: params.accessToken || this.accessToken, - }) - } - - /** - * Determines the URL for a previewed document during an active preview - * session. The result of this method should be used to redirect the user to - * the document's URL. - * - * @example - * - * ```ts - * const url = client.resolvePreviewURL({ - * linkResolver: (document) => `/${document.uid}` - * defaultURL: '/' - * }) - * ``` - * - * @param args - Arguments to configure the URL resolving. - * - * @returns The URL for the previewed document during an active preview - * session. The user should be redirected to this URL. - */ - async resolvePreviewURL( - args: ResolvePreviewArgs & FetchParams, - ): Promise { - let documentID: string | undefined | null = args.documentID - let previewToken: string | undefined | null = args.previewToken - - if (typeof globalThis.location !== "undefined") { - const searchParams = new URLSearchParams(globalThis.location.search) - - documentID = documentID || searchParams.get("documentId") - previewToken = previewToken || searchParams.get("token") - } else if (this.refState.httpRequest) { - if ("query" in this.refState.httpRequest) { - documentID = - documentID || (this.refState.httpRequest.query?.documentId as string) - previewToken = - previewToken || (this.refState.httpRequest.query?.token as string) - } else if ( - "url" in this.refState.httpRequest && - this.refState.httpRequest.url - ) { - // Including "missing-host://" by default - // handles a case where Next.js Route Handlers - // only provide the pathname and search - // parameters in the `url` property - // (e.g. `/api/preview?foo=bar`). - const searchParams = new URL( - this.refState.httpRequest.url, - "missing-host://", - ).searchParams - - documentID = documentID || searchParams.get("documentId") - previewToken = previewToken || searchParams.get("token") - } - } - - if (documentID != null && previewToken != null) { - const document = await this.getByID(documentID, { - ref: previewToken, - lang: "*", - signal: args.signal, - fetchOptions: args.fetchOptions, - }) - - const url = asLink(document, { linkResolver: args.linkResolver }) - - if (typeof url === "string") { - return url - } - } - - return args.defaultURL - } - - /** - * Configures the client to query the latest published content for all future - * queries. - * - * If the `ref` parameter is provided during a query, it takes priority for - * that query. - * - * @example - * - * ```ts - * await client.queryLatestContent() - * const document = await client.getByID("WW4bKScAAMAqmluX") - * ``` - */ - queryLatestContent(): void { - this.refState.mode = RefStateMode.Master - } - - /** - * Configures the client to query content from a specific Release identified - * by its ID for all future queries. - * - * If the `ref` parameter is provided during a query, it takes priority for - * that query. - * - * @example - * - * ```ts - * await client.queryContentFromReleaseByID("YLB7OBAAACMA7Cpa") - * const document = await client.getByID("WW4bKScAAMAqmluX") - * ``` - * - * @param releaseID - The ID of the Release. - */ - queryContentFromReleaseByID(releaseID: string): void { - this.refState = { - ...this.refState, - mode: RefStateMode.ReleaseID, - releaseID, - } - } - - /** - * Configures the client to query content from a specific Release identified - * by its label for all future queries. - * - * If the `ref` parameter is provided during a query, it takes priority for - * that query. - * - * @example - * - * ```ts - * await client.queryContentFromReleaseByLabel("My Release") - * const document = await client.getByID("WW4bKScAAMAqmluX") - * ``` - * - * @param releaseLabel - The label of the Release. - */ - queryContentFromReleaseByLabel(releaseLabel: string): void { - this.refState = { - ...this.refState, - mode: RefStateMode.ReleaseLabel, - releaseLabel, - } - } - - /** - * Configures the client to query content from a specific ref. The ref can be - * provided as a string or a function. - * - * If a function is provided, the ref is fetched lazily before each query. The - * function may also be asynchronous. - * - * @example - * - * ```ts - * await client.queryContentFromRef("my-ref") - * const document = await client.getByID("WW4bKScAAMAqmluX") - * ``` - * - * @param ref - The ref or a function that returns the ref from which to query - * content. - */ - queryContentFromRef(ref: RefStringOrThunk): void { - this.refState = { - ...this.refState, - mode: RefStateMode.Manual, - ref, - } - } - - /** - * A `fetch()` function to be used with GraphQL clients configured for - * Prismic's GraphQL API. It automatically applies the necessary `prismic-ref` - * and Authorization headers. Queries will automatically be minified by - * removing whitespace where possible. - * - * @example - * - * ```ts - * const graphQLClient = new ApolloClient({ - * link: new HttpLink({ - * uri: prismic.getGraphQLEndpoint(repositoryName), - * // Provide `client.graphQLFetch` as the fetch implementation. - * fetch: client.graphQLFetch, - * // Using GET is required. - * useGETForQueries: true, - * }), - * cache: new InMemoryCache(), - * }) - * ``` - * - * @param input - The `fetch()` `input` parameter. Only strings are supported. - * @param init - The `fetch()` `init` parameter. Only plain objects are - * supported. - * - * @returns The `fetch()` Response for the request. - * - * @experimental - */ - async graphQLFetch( - input: RequestInfo, - init?: Omit & { signal?: AbortSignalLike }, - ): Promise { - const cachedRepository = await this.getCachedRepository() - const ref = await this.getResolvedRefString() - - const unsanitizedHeaders: Record = { - "Prismic-ref": ref, - Authorization: this.accessToken ? `Token ${this.accessToken}` : "", - // Asserting `init.headers` is a Record since popular GraphQL - // libraries pass this as a Record. Header objects as input - // are unsupported. - ...(init ? (init.headers as Record) : {}), - } - - if (cachedRepository.integrationFieldsRef) { - unsanitizedHeaders["Prismic-integration-field-ref"] = - cachedRepository.integrationFieldsRef - } - - // Normalize header keys to lowercase. This prevents header - // conflicts between the Prismic client and the GraphQL - // client. - const headers: Record = {} - for (const key in unsanitizedHeaders) { - if (unsanitizedHeaders[key]) { - headers[key.toLowerCase()] = - unsanitizedHeaders[key as keyof typeof unsanitizedHeaders] - } - } - - const url = new URL( - // Asserting `input` is a string since popular GraphQL - // libraries pass this as a string. Request objects as - // input are unsupported. - input as string, - ) - - // This prevents the request from being cached unnecessarily. - // Without adding this `ref` param, re-running a query - // could return a locally cached response, even if the - // `ref` changed. This happens because the URL is - // identical when the `ref` is not included. Caches may ignore - // headers. - // - // The Prismic GraphQL API ignores the `ref` param. - url.searchParams.set("ref", ref) - - const query = url.searchParams.get("query") - if (query) { - url.searchParams.set( - "query", - // Compress the GraphQL query (if it exists) by - // removing whitespace. This is done to - // optimize the query size and avoid - // hitting the upper limit of GET requests - // (2048 characters). - minifyGraphQLQuery(query), - ) - } - - return (await this.fetchFn(url.toString(), { - ...init, - headers, - })) as Response - } - - /** - * Returns a cached version of `getRepository` with a TTL. - * - * @returns Cached repository metadata. - */ - private async getCachedRepository(params?: FetchParams): Promise { - if ( - !this.cachedRepository || - Date.now() >= this.cachedRepositoryExpiration - ) { - this.cachedRepositoryExpiration = Date.now() + REPOSITORY_CACHE_TTL - this.cachedRepository = await this.getRepository(params) - } - - return this.cachedRepository - } - - /** - * Returns a cached Prismic repository form. Forms are used to determine API - * endpoints for types of repository data. - * - * @param name - Name of the form. - * - * @returns The repository form. - * - * @throws If a matching form cannot be found. - */ - private async getCachedRepositoryForm( - name: string, - params?: FetchParams, - ): Promise { - const cachedRepository = await this.getCachedRepository(params) - const form = cachedRepository.forms[name] - - if (!form) { - throw new PrismicError( - `Form with name "${name}" could not be found`, - undefined, - undefined, - ) - } - - return form - } - - /** - * Returns the ref needed to query based on the client's current state. This - * method may make a network request to fetch a ref or resolve the user's ref - * thunk. - * - * If auto previews are enabled, the preview ref takes priority if available. - * - * The following strategies are used depending on the client's state: - * - * - If the user called `queryLatestContent`: Use the repository's master ref. - * The ref is cached for 5 seconds. After 5 seconds, a new master ref is - * fetched. - * - If the user called `queryContentFromReleaseByID`: Use the release's ref. - * The ref is cached for 5 seconds. After 5 seconds, a new ref for the - * release is fetched. - * - If the user called `queryContentFromReleaseByLabel`: Use the release's ref. - * The ref is cached for 5 seconds. After 5 seconds, a new ref for the - * release is fetched. - * - If the user called `queryContentFromRef`: Use the provided ref. Fall back - * to the master ref if the ref is not a string. - * - * @returns The ref to use during a query. - */ - private async getResolvedRefString(params?: FetchParams): Promise { - if (this.refState.autoPreviewsEnabled) { - let previewRef: string | undefined - - let cookieJar: string | null | undefined - - if (this.refState.httpRequest?.headers) { - if ( - "get" in this.refState.httpRequest.headers && - typeof this.refState.httpRequest.headers.get === "function" - ) { - // Web API Headers - cookieJar = this.refState.httpRequest.headers.get("cookie") - } else if ("cookie" in this.refState.httpRequest.headers) { - // Express-style headers - cookieJar = this.refState.httpRequest.headers.cookie - } - } else if (globalThis.document?.cookie) { - cookieJar = globalThis.document.cookie - } - - if (cookieJar) { - previewRef = getPreviewCookie(cookieJar) - } - - if (previewRef) { - return previewRef - } - } - - const cachedRepository = await this.getCachedRepository(params) - - const refModeType = this.refState.mode - if (refModeType === RefStateMode.ReleaseID) { - return findRefByID(cachedRepository.refs, this.refState.releaseID).ref - } else if (refModeType === RefStateMode.ReleaseLabel) { - return findRefByLabel(cachedRepository.refs, this.refState.releaseLabel) - .ref - } else if (refModeType === RefStateMode.Manual) { - const res = await castThunk(this.refState.ref)() - - if (typeof res === "string") { - return res - } - } - - return findMasterRef(cachedRepository.refs).ref - } - - /** - * Performs a network request using the configured `fetch` function. It - * assumes all successful responses will have a JSON content type. It also - * normalizes unsuccessful network requests. - * - * @typeParam T - The JSON response. - * - * @param url - URL to the resource to fetch. - * @param params - Prismic REST API parameters for the network request. - * - * @returns The JSON response from the network request. - */ - private async fetch( - url: string, - params: FetchParams = {}, - ): Promise { - const requestInit: RequestInitLike = { - ...this.fetchOptions, - ...params.fetchOptions, - headers: { - ...this.fetchOptions?.headers, - ...params.fetchOptions?.headers, - }, - signal: - params.fetchOptions?.signal || - params.signal || - this.fetchOptions?.signal, - } - - let job: Promise - - // `fetchJobs` is keyed twice: first by the URL and again by is - // signal, if one exists. - // - // Using two keys allows us to reuse fetch requests for - // equivalent URLs, but eject when we detect unique signals. - if (this.fetchJobs[url] && this.fetchJobs[url].has(requestInit.signal)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - job = this.fetchJobs[url].get(requestInit.signal)! - } else { - this.fetchJobs[url] = this.fetchJobs[url] || new Map() - - job = this.fetchFn(url, requestInit) - .then(async (res) => { - // We can assume Prismic REST API responses - // will have a `application/json` - // Content Type. If not, this will - // throw, signaling an invalid - // response. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let json: any = undefined - try { - json = await res.json() - } catch { - // noop - } - - return { - status: res.status, - headers: res.headers, - json, - } - }) - .finally(() => { - this.fetchJobs[url].delete(requestInit.signal) - - if (this.fetchJobs[url].size === 0) { - delete this.fetchJobs[url] - } - }) - - this.fetchJobs[url].set(requestInit.signal, job) - } - - const res = await job - - if (res.status !== 404 && res.json == null) { - throw new PrismicError(undefined, url, res.json) - } - - switch (res.status) { - // Successful - case 200: { - return res.json - } - - // Bad Request - // - Invalid filter syntax - // - Ref not provided (ignored) - case 400: { - throw new ParsingError(res.json.message, url, res.json) - } - - // Unauthorized - // - Missing access token for repository endpoint - // - Incorrect access token for repository endpoint - case 401: - // Forbidden - // - Missing access token for query endpoint - // - Incorrect access token for query endpoint - case 403: { - throw new ForbiddenError( - res.json.error || res.json.message, - url, - res.json, - ) - } - - // Not Found - // - Incorrect repository name (this response has an empty body) - // - Ref does not exist - // - Preview token is expired - case 404: { - if (res.json === undefined) { - throw new RepositoryNotFoundError( - `Prismic repository not found. Check that "${this.endpoint}" is pointing to the correct repository.`, - url, - undefined, - ) - } - - if (res.json.type === "api_notfound_error") { - throw new RefNotFoundError(res.json.message, url, res.json) - } - - if ( - res.json.type === "api_security_error" && - /preview token.*expired/i.test(res.json.message) - ) { - throw new PreviewTokenExpiredError(res.json.message, url, res.json) - } - - throw new NotFoundError(res.json.message, url, res.json) - } - - // Gone - // - Ref is expired - case 410: { - throw new RefExpiredError(res.json.message, url, res.json) - } - - // Too Many Requests - // - Exceeded the maximum number of requests per second - case 429: { - const parsedRetryAfter = Number(res.headers.get("retry-after")) - const delay = Number.isNaN(parsedRetryAfter) - ? DEFUALT_RETRY_AFTER_MS - : parsedRetryAfter - - return await new Promise((resolve, reject) => { - setTimeout(async () => { - try { - resolve(await this.fetch(url, params)) - } catch (error) { - reject(error) - } - }, delay) - }) - } - } - - throw new PrismicError(undefined, url, res.json) - } -} diff --git a/src/createMigration.ts b/src/createMigration.ts new file mode 100644 index 00000000..b12df736 --- /dev/null +++ b/src/createMigration.ts @@ -0,0 +1,31 @@ +import type { PrismicDocument } from "./types/value/document" + +import { Migration } from "./Migration" + +/** + * Type definitions for the `createMigration()` function. May be augmented by + * third-party libraries. + */ +export interface CreateMigration { + ( + ...args: ConstructorParameters + ): Migration +} + +/** + * Creates a Prismic migration instance that can be used to prepare your + * migration to Prismic. + * + * @example + * + * ```ts + * createMigration() + * ``` + * + * @typeParam TDocuments - A union of Prismic document types for the repository. + * + * @returns A migration instance to prepare your migration. + */ +export const createMigration: CreateMigration = < + TDocuments extends PrismicDocument, +>() => new Migration() diff --git a/src/createWriteClient.ts b/src/createWriteClient.ts new file mode 100644 index 00000000..d713050f --- /dev/null +++ b/src/createWriteClient.ts @@ -0,0 +1,43 @@ +import type { PrismicDocument } from "./types/value/document" + +import type { WriteClientConfig } from "./WriteClient" +import { WriteClient } from "./WriteClient" + +/** + * Type definitions for the `createWriteClient()` function. May be augmented by + * third-party libraries. + */ +export interface CreateWriteClient { + ( + ...args: ConstructorParameters + ): WriteClient +} + +/** + * Creates a Prismic client that can be used to query and write content to a + * repository. + * + * @remarks + * This client works in environments supporting File, Blob, and FormData, + * including Node.js 20 and later. + * + * @example + * + * ```ts + * createWriteClient("qwerty", { writeToken: "***" }) + * ``` + * + * @typeParam TDocuments - A union of Prismic document types for the repository. + * + * @param repositoryName - The Prismic repository name for the repository. + * @param options - Configuration that determines how content will be queried + * from and written to the Prismic repository. + * + * @returns A client that can query and write content to the repository. + */ +export const createWriteClient: CreateWriteClient = < + TDocuments extends PrismicDocument, +>( + repositoryName: string, + options: WriteClientConfig, +) => new WriteClient(repositoryName, options) diff --git a/src/getRepositoryName.ts b/src/getRepositoryName.ts index 08f16998..74d04ce8 100644 --- a/src/getRepositoryName.ts +++ b/src/getRepositoryName.ts @@ -1,27 +1,32 @@ import { PrismicError } from "./errors/PrismicError" /** - * Get a Prismic repository's name from its standard Prismic Rest API V2 or + * Get a Prismic repository's name from its standard Prismic Document API or * GraphQL endpoint. * - * @typeParam RepositoryEndpoint - Prismic Rest API V2 endpoint for the - * repository. - * - * @param repositoryEndpoint - Prismic Rest API V2 endpoint for the repository. + * @param repositoryEndpoint - Prismic Document API endpoint for the repository. * * @returns The Prismic repository's name. * - * @throws {@link Error} Thrown if an invalid Prismic Rest API V2 endpoint is + * @throws {@link Error} Thrown if an invalid Prismic Document API endpoint is * provided. */ export const getRepositoryName = (repositoryEndpoint: string): string => { try { - return new URL(repositoryEndpoint).hostname.split(".")[0] - } catch { - throw new PrismicError( - `An invalid Prismic Rest API V2 endpoint was provided: ${repositoryEndpoint}`, - undefined, - undefined, - ) - } + const hostname = new URL(repositoryEndpoint).hostname + + if ( + hostname.endsWith("prismic.io") || // Production + hostname.endsWith("wroom.io") || // Staging + hostname.endsWith("wroom.test") // Dev + ) { + return hostname.split(".")[0] + } + } catch {} + + throw new PrismicError( + `An invalid Prismic Document API endpoint was provided: ${repositoryEndpoint}`, + undefined, + undefined, + ) } diff --git a/src/index.ts b/src/index.ts index 157d3d8e..81ebb0ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,16 @@ import { filter } from "./filter" //============================================================================= // Primary Client API. -export { createClient, Client } from "./createClient" +export { createClient } from "./createClient" +export { Client } from "./Client" + +// Write Client API. +export { createWriteClient } from "./createWriteClient" +export { WriteClient } from "./WriteClient" + +// Migration helper. +export { createMigration } from "./createMigration" +export { Migration } from "./Migration" // API endpoint helpers. export { getRepositoryEndpoint } from "./getRepositoryEndpoint" @@ -41,21 +50,24 @@ export { filter, predicate } export * as cookie from "./cookie" // General types used to query content from Prismic. These are made public to allow users to better type their projects. +export type { CreateClient } from "./createClient" +export type { ClientConfig } from "./Client" +export type { CreateWriteClient } from "./createWriteClient" +export type { WriteClientConfig, MigrateReporterEvents } from "./WriteClient" export type { AbortSignalLike, - ClientConfig, - CreateClient, FetchLike, HttpRequestLike, RequestInitLike, ResponseLike, -} from "./createClient" +} from "./BaseClient" export type { BuildQueryURLArgs, Ordering, QueryParams, Route, } from "./buildQueryURL" +export type { CreateMigration } from "./createMigration" //============================================================================= // Helpers - Manipulate content from Prismic. @@ -313,6 +325,22 @@ export type { CustomTypeModelFieldForSlicePrimary, } from "./types/model/types" +// Migrations - Types representing Prismic Migration API content values. +export { PrismicMigrationDocument } from "./types/migration/Document" +export type { + PendingPrismicDocument, + ExistingPrismicDocument, + InjectMigrationSpecificTypes, +} from "./types/migration/Document" + +export { PrismicMigrationAsset } from "./types/migration/Asset" +export type { + MigrationImage, + MigrationLinkToMedia, + MigrationRTImageNode, +} from "./types/migration/Asset" +export type { MigrationContentRelationship } from "./types/migration/ContentRelationship" + // API - Types representing Prismic Rest API V2 responses. export type { Query } from "./types/api/query" diff --git a/src/lib/isMigrationValue.ts b/src/lib/isMigrationValue.ts new file mode 100644 index 00000000..28a442e1 --- /dev/null +++ b/src/lib/isMigrationValue.ts @@ -0,0 +1,126 @@ +import type { + MigrationImage, + MigrationLinkToMedia, + MigrationRTImageNode, +} from "../types/migration/Asset" +import { PrismicMigrationAsset } from "../types/migration/Asset" +import type { MigrationContentRelationship } from "../types/migration/ContentRelationship" +import { + type InjectMigrationSpecificTypes, + PrismicMigrationDocument, +} from "../types/migration/Document" +import type { PrismicDocument } from "../types/value/document" +import type { GroupField } from "../types/value/group" +import { LinkType } from "../types/value/link" +import { RichTextNodeType } from "../types/value/richText" +import type { SliceZone } from "../types/value/sliceZone" +import type { AnyRegularField } from "../types/value/types" + +import * as is from "./isValue" + +/** + * Unknown value to check if it's a specific field type. + * + * @remarks + * Explicit types are added to help ensure narrowing is done effectively. + */ +type UnknownValue = + | PrismicDocument + | InjectMigrationSpecificTypes + | unknown + +/** + * Checks if a value is a migration content relationship field. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a migration content relationship field, `false` + * otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const contentRelationship = ( + value: UnknownValue, +): value is MigrationContentRelationship => { + return ( + value instanceof PrismicMigrationDocument || + is.prismicDocument(value) || + (typeof value === "object" && + value !== null && + "link_type" in value && + value.link_type === LinkType.Document && + "id" in value && + (contentRelationship(value.id) || typeof value.id === "function")) + ) +} + +/** + * Checks if a value is a migration image field. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a migration image field, `false` otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const image = (value: UnknownValue): value is MigrationImage => { + return ( + value instanceof PrismicMigrationAsset || + (typeof value === "object" && + value !== null && + "id" in value && + Object.values(value).every( + (maybeThumbnail) => maybeThumbnail instanceof PrismicMigrationAsset, + )) + ) +} + +/** + * Checks if a value is a migration link to media field. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a migration link to media field, `false` + * otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const linkToMedia = ( + value: UnknownValue, +): value is MigrationLinkToMedia => { + return ( + typeof value === "object" && + value !== null && + "id" in value && + value.id instanceof PrismicMigrationAsset && + "link_type" in value && + value.link_type === LinkType.Media + ) +} + +/** + * Checks if a value is a migration rich text image node. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a migration rich text image node, `false` + * otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const rtImageNode = ( + value: UnknownValue, +): value is MigrationRTImageNode => { + return ( + typeof value === "object" && + value !== null && + "id" in value && + value.id instanceof PrismicMigrationAsset && + "type" in value && + value.type === RichTextNodeType.image + ) +} diff --git a/src/lib/isValue.ts b/src/lib/isValue.ts new file mode 100644 index 00000000..c4b5bd11 --- /dev/null +++ b/src/lib/isValue.ts @@ -0,0 +1,196 @@ +import type { InjectMigrationSpecificTypes } from "../types/migration/Document" +import type { FilledContentRelationshipField } from "../types/value/contentRelationship" +import type { PrismicDocument } from "../types/value/document" +import type { GroupField } from "../types/value/group" +import type { ImageField } from "../types/value/image" +import { LinkType } from "../types/value/link" +import type { FilledLinkToMediaField } from "../types/value/linkToMedia" +import { type RTImageNode, RichTextNodeType } from "../types/value/richText" +import type { SliceZone } from "../types/value/sliceZone" +import type { AnyRegularField } from "../types/value/types" + +/** + * Unknown value to check if it's a specific field type. + * + * @remarks + * Explicit types are added to help ensure narrowing is done effectively. + */ +type UnknownValue = + | PrismicDocument + | InjectMigrationSpecificTypes + | unknown + +/** + * Checks if a value is a link to media field. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a link to media field, `false` otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const filledLinkToMedia = ( + value: UnknownValue, +): value is FilledLinkToMediaField => { + if (value && typeof value === "object" && !("version" in value)) { + if ( + "link_type" in value && + value.link_type === LinkType.Media && + "id" in value && + "name" in value && + "kind" in value && + "url" in value && + "size" in value + ) { + value + + return true + } + } + + return false +} + +/** + * Checks if a value is like an image field. + * + * @param value - Value to check. + * + * @returns `true` if `value` is like an image field, `false` otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +const imageLike = ( + value: UnknownValue, +): value is ImageField | RTImageNode => { + if ( + value && + typeof value === "object" && + (!("version" in value) || typeof value.version === "object") + ) { + if ( + "id" in value && + "url" in value && + typeof value.url === "string" && + "dimensions" in value && + "edit" in value && + "alt" in value && + "copyright" in value + ) { + return true + } + } + + return false +} + +/** + * Checks if a value is an image field. + * + * @param value - Value to check. + * + * @returns `true` if `value` is an image field, `false` otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const filledImage = ( + value: UnknownValue, +): value is ImageField => { + if ( + imageLike(value) && + (!("type" in value) || value.type !== RichTextNodeType.image) + ) { + value + + return true + } + + return false +} + +/** + * Checks if a value is a rich text image node. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a rich text image node, `false` otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const rtImageNode = (value: UnknownValue): value is RTImageNode => { + if ( + imageLike(value) && + "type" in value && + value.type === RichTextNodeType.image + ) { + value + + return true + } + + return false +} + +/** + * Checks if a value is a content relationship field. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a content relationship, `false` otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const filledContentRelationship = ( + value: UnknownValue, +): value is FilledContentRelationshipField => { + if (value && typeof value === "object" && !("version" in value)) { + if ( + "link_type" in value && + value.link_type === LinkType.Document && + "id" in value && + "type" in value && + "tags" in value && + "lang" in value + ) { + return true + } + } + + return false +} + +/** + * Checks if a value is a Prismic document. + * + * @param value - Value to check. + * + * @returns `true` if `value` is a Prismic document, `false` otherwise. + * + * @internal + * This is not an official helper function and it's only designed to work with internal processes. + */ +export const prismicDocument = ( + value: UnknownValue, +): value is PrismicDocument => { + try { + return ( + typeof value === "object" && + value !== null && + "id" in value && + "href" in value && + typeof value.href === "string" && + new URL(value.href) && + "type" in value && + "lang" in value && + "tags" in value && + Array.isArray(value.tags) + ) + } catch { + return false + } +} diff --git a/src/lib/pLimit.ts b/src/lib/pLimit.ts new file mode 100644 index 00000000..13eab709 --- /dev/null +++ b/src/lib/pLimit.ts @@ -0,0 +1,106 @@ +/* + ** Core logic from https://github.com/sindresorhus/p-limit + ** Many thanks to @sindresorhus + */ + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFunction = (...arguments_: readonly any[]) => unknown + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + +/** + * @param fn - Promise-returning/async function. + * @param arguments - Any arguments to pass through to `fn`. Support for passing + * arguments on to the `fn` is provided in order to be able to avoid creating + * unnecessary closures. You probably don't need this optimization unless + * you're pushing a lot of functions. + * + * @returns The promise returned by calling `fn(...arguments)`. + */ +export type LimitFunction = ( + function_: ( + ...arguments_: TArguments + ) => PromiseLike | TReturnType, + ...arguments_: TArguments +) => Promise + +/** + * Creates a limiting function that will only execute one promise at a time and + * respect a given interval between each call. + * + * @param args - Options for the function, `interval` is the minimum time to + * wait between each promise execution. + * + * @returns A limiting function as per configuration, see {@link LimitFunction}. + */ +export const pLimit = ({ + interval, +}: { interval?: number } = {}): LimitFunction => { + const queue: AnyFunction[] = [] + let busy = false + let lastCompletion = 0 + + const resumeNext = () => { + if (!busy && queue.length > 0) { + queue.shift()?.() + busy = true + } + } + + const next = () => { + busy = false + + resumeNext() + } + + const run = async ( + function_: AnyFunction, + resolve: (value: unknown) => void, + arguments_: unknown[], + ) => { + const timeSinceLastCompletion = Date.now() - lastCompletion + + if (interval && timeSinceLastCompletion < interval) { + await sleep(interval - timeSinceLastCompletion) + } + const result = (async () => function_(...arguments_))() + + resolve(result) + + try { + await result + } catch {} + + lastCompletion = Date.now() + + next() + } + + const enqueue = ( + function_: AnyFunction, + resolve: (value: unknown) => void, + arguments_: unknown[], + ) => { + // Queue `internalResolve` instead of the `run` function + // to preserve asynchronous context. + new Promise((internalResolve) => { + queue.push(internalResolve) + }).then(run.bind(undefined, function_, resolve, arguments_)) + ;(async () => { + // This function needs to wait until the next microtask before comparing + // `activeCount` to `concurrency`, because `activeCount` is updated asynchronously + // after the `internalResolve` function is dequeued and called. The comparison in the if-statement + // needs to happen asynchronously as well to get an up-to-date value for `activeCount`. + await Promise.resolve() + + if (!busy) { + resumeNext() + } + })() + } + + return ((function_: AnyFunction, ...arguments_: unknown[]) => + new Promise((resolve) => { + enqueue(function_, resolve, arguments_) + })) as LimitFunction +} diff --git a/src/lib/resolveMigrationDocumentData.ts b/src/lib/resolveMigrationDocumentData.ts new file mode 100644 index 00000000..dded02c9 --- /dev/null +++ b/src/lib/resolveMigrationDocumentData.ts @@ -0,0 +1,233 @@ +import type { MigrationLinkToMediaField } from "../types/migration/Asset" +import { + type MigrationImage, + type MigrationLinkToMedia, + type MigrationRTImageNode, + PrismicMigrationAsset, +} from "../types/migration/Asset" +import type { + MigrationContentRelationship, + MigrationContentRelationshipField, +} from "../types/migration/ContentRelationship" +import { PrismicMigrationDocument } from "../types/migration/Document" +import type { FilledImageFieldImage } from "../types/value/image" +import type { LinkField } from "../types/value/link" +import { LinkType } from "../types/value/link" +import type { RTImageNode } from "../types/value/richText" +import { RichTextNodeType } from "../types/value/richText" + +import * as isFilled from "../helpers/isFilled" +import type { Migration } from "../Migration" + +import * as isMigration from "./isMigrationValue" + +/** + * Resolves a migration content relationship to a content relationship field. + * + * @param relation - Content relationship to resolve. + * + * @returns Resolved content relationship field. + */ +export async function resolveMigrationContentRelationship( + relation: MigrationContentRelationship, +): Promise { + if (typeof relation === "function") { + return resolveMigrationContentRelationship(await relation()) + } + + if (relation instanceof PrismicMigrationDocument) { + return relation.document.id + ? { link_type: LinkType.Document, id: relation.document.id } + : { link_type: LinkType.Document } + } + + if (relation) { + if ( + isMigration.contentRelationship(relation.id) || + typeof relation.id === "function" + ) { + return { + ...(await resolveMigrationContentRelationship(relation.id)), + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + text: relation.text, + } + } + + return { link_type: LinkType.Document, id: relation.id } + } + + return { link_type: LinkType.Document } +} + +/** + * Resolves a migration image to an image field. + * + * @param migrationAsset - Asset to resolve. + * @param migration - Migration instance. + * @param withThumbnails - Whether to include thumbnails. + * + * @returns Resolved image field. + */ +export const resolveMigrationImage = ( + image: MigrationImage, + migration: Migration, + withThumbnails?: boolean, +): FilledImageFieldImage | undefined => { + const { id: master, ...thumbnails } = + image instanceof PrismicMigrationAsset ? { id: image } : image + + const asset = migration._assets.get(master.config.id)?.asset + const maybeInitialField = master.originalField + + if (asset) { + const parameters = (maybeInitialField?.url || asset.url).split("?")[1] + const url = `${asset.url.split("?")[0]}${parameters ? `?${parameters}` : ""}` + const dimensions: FilledImageFieldImage["dimensions"] = { + width: asset.width!, + height: asset.height!, + } + const edit: FilledImageFieldImage["edit"] = + maybeInitialField && "edit" in maybeInitialField + ? maybeInitialField?.edit + : { x: 0, y: 0, zoom: 1, background: "transparent" } + + // We give priority to the asset's specific alt text, then the image's general alt text + const alt = master.config.alt || asset.alt || null + + const resolvedThumbnails: Record = {} + if (withThumbnails) { + for (const [name, thumbnail] of Object.entries(thumbnails)) { + const resolvedThumbnail = resolveMigrationImage(thumbnail, migration) + if (resolvedThumbnail) { + resolvedThumbnails[name] = resolvedThumbnail + } + } + } + + return { + id: asset.id, + url, + dimensions, + edit, + alt: alt, + copyright: asset.credits || null, + ...resolvedThumbnails, + } + } +} + +/** + * Resolves a migration rich text image node to a regular rich text image node. + * + * @param rtImageNode - Migration rich text image node to resolve. + * @param migration - Migration instance. + * + * @returns Resolved rich text image node. + */ +export const resolveMigrationRTImageNode = async ( + rtImageNode: MigrationRTImageNode, + migration: Migration, +): Promise => { + const image = resolveMigrationImage(rtImageNode.id, migration) + + if (image) { + const linkTo = (await resolveMigrationDocumentData( + rtImageNode.linkTo, + migration, + )) as LinkField + + return { + ...image, + type: RichTextNodeType.image, + linkTo: isFilled.link(linkTo) ? linkTo : undefined, + } + } +} + +/** + * Resolves a migration link to media to a regular link to media field. + * + * @param linkToMedia - Migration link to media to resolve. + * @param migration - Migration instance. + * + * @returns Resolved link to media field. + */ +export const resolveMigrationLinkToMedia = ( + linkToMedia: MigrationLinkToMedia, + migration: Migration, +): MigrationLinkToMediaField => { + const asset = migration._assets.get(linkToMedia.id.config.id)?.asset + + if (asset) { + return { + id: asset.id, + link_type: LinkType.Media, + text: linkToMedia.text, + } + } + + return { link_type: LinkType.Media } +} + +/** + * Resolves a migration document data to actual data ready to be sent to the + * Migration API. + * + * @param input - Migration link to media to resolve. + * @param migration - Migration instance. + * + * @returns Resolved data. + */ +export async function resolveMigrationDocumentData( + input: unknown, + migration: Migration, +): Promise { + // Migration fields + if (isMigration.contentRelationship(input)) { + return resolveMigrationContentRelationship(input) + } + + if (isMigration.image(input)) { + return resolveMigrationImage(input, migration, true) + } + + if (isMigration.linkToMedia(input)) { + return resolveMigrationLinkToMedia(input, migration) + } + + if (isMigration.rtImageNode(input)) { + return resolveMigrationRTImageNode(input, migration) + } + + if (typeof input === "function") { + return await resolveMigrationDocumentData(await input(), migration) + } + + // Object traversing + if (Array.isArray(input)) { + const res = [] + + for (const element of input) { + res.push(await resolveMigrationDocumentData(element, migration)) + } + + return res.filter(Boolean) + } + + if (input && typeof input === "object") { + const res: Record = {} + + for (const key in input) { + res[key] = await resolveMigrationDocumentData( + input[key as keyof typeof input], + migration, + ) + } + + return res + } + + // Primitives + return input +} diff --git a/src/lib/validateAssetMetadata.ts b/src/lib/validateAssetMetadata.ts new file mode 100644 index 00000000..19720f8d --- /dev/null +++ b/src/lib/validateAssetMetadata.ts @@ -0,0 +1,82 @@ +import { PrismicError } from "../errors/PrismicError" + +import type { CreateAssetParams } from "../WriteClient" + +/** + * Max length for asset notes accepted by the API. + */ +const ASSET_NOTES_MAX_LENGTH = 500 + +/** + * Max length for asset credits accepted by the API. + */ +const ASSET_CREDITS_MAX_LENGTH = 500 + +/** + * Max length for asset alt text accepted by the API. + */ +const ASSET_ALT_MAX_LENGTH = 500 + +/** + * Min length for asset tags accepted by the API. + */ +const ASSET_TAG_MIN_LENGTH = 3 + +/** + * Max length for asset tags accepted by the API. + */ +const ASSET_TAG_MAX_LENGTH = 20 + +/** + * Validates an asset's metadata, throwing an error if any of the metadata are + * invalid. + * + * @param assetMetadata - The asset metadata to validate. + * + * @internal + */ +export const validateAssetMetadata = ({ + notes, + credits, + alt, + tags, +}: CreateAssetParams): void => { + const errors: string[] = [] + + if (notes && notes.length > ASSET_NOTES_MAX_LENGTH) { + errors.push( + `\`notes\` must be at most ${ASSET_NOTES_MAX_LENGTH} characters`, + ) + } + + if (credits && credits.length > ASSET_CREDITS_MAX_LENGTH) { + errors.push( + `\`credits\` must be at most ${ASSET_CREDITS_MAX_LENGTH} characters`, + ) + } + + if (alt && alt.length > ASSET_ALT_MAX_LENGTH) { + errors.push(`\`alt\` must be at most ${ASSET_ALT_MAX_LENGTH} characters`) + } + + if ( + tags && + tags.length && + tags.some( + (tag) => + tag.length < ASSET_TAG_MIN_LENGTH || tag.length > ASSET_TAG_MAX_LENGTH, + ) + ) { + errors.push( + `tags must be at least 3 characters long and 20 characters at most`, + ) + } + + if (errors.length) { + throw new PrismicError( + `Errors validating asset metadata: ${errors.join(", ")}`, + undefined, + { notes, credits, alt, tags }, + ) + } +} diff --git a/src/types/api/asset/asset.ts b/src/types/api/asset/asset.ts new file mode 100644 index 00000000..5115474f --- /dev/null +++ b/src/types/api/asset/asset.ts @@ -0,0 +1,211 @@ +import type { AssetTag } from "./tag" + +/** + * Asset types. + */ +export const AssetType = { + All: "all", + Audio: "audio", + Document: "document", + Image: "image", + Video: "video", +} as const + +/** + * An object representing an asset returned by the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type Asset = { + /** + * Asset ID. + */ + id: string + + /** + * Asset URL. + */ + url: string + + /** + * Asset creation date. + */ + created_at: number + + /** + * Asset last modification date. + */ + last_modified: number + + /** + * Asset filename. + */ + filename: string + + /** + * Asset extension. + */ + extension: string + + /** + * Asset size in bytes. + */ + size: number + + /** + * Asset kind. + */ + kind: Exclude< + (typeof AssetType)[keyof typeof AssetType], + (typeof AssetType)["All"] + > + + /** + * Asset width in pixels. + */ + width?: number + + /** + * Asset height in pixels. + */ + height?: number + + /** + * Asset notes. + */ + notes?: string + + /** + * Asset credits. + */ + credits?: string + + /** + * Asset alt text. + */ + alt?: string + + /** + * Asset tags. + */ + tags?: AssetTag[] + + /** + * @internal + */ + origin_url?: string + + /** + * @internal + */ + uploader_id?: string + + /** + * @internal + */ + search_highlight?: { + filename?: string[] + notes?: string[] + credits?: string[] + alt?: string[] + } +} + +/** + * Available query parameters when querying assets from the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type GetAssetsParams = { + // Pagination + /** + * Number of items to return. + */ + pageSize?: number + /** + * @internal + */ + cursor?: string + + // Filtering + /** + * Asset type to filter by. + */ + assetType?: (typeof AssetType)[keyof typeof AssetType] + + /** + * Search query. + */ + keyword?: string + + /** + * Asset IDs to filter by. + */ + ids?: string[] + + /** + * Asset tags to filter by. + */ + tags?: string[] +} + +/** + * An object representing the result of querying assets from the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type GetAssetsResult = { + items: Asset[] + total: number + cursor?: string + missing_ids?: string[] + is_opensearch_result: boolean +} + +/** + * Parameters for uploading an asset to the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PostAssetParams = { + file: BlobPart + notes?: string + credits?: string + alt?: string +} + +/** + * Result of uploading an asset to the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PostAssetResult = Asset + +/** + * Parameters for updating an asset in the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PatchAssetParams = { + notes?: string + credits?: string + alt?: string + filename?: string + tags?: string[] +} + +/** + * Result of updating an asset in the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PatchAssetResult = Asset + +/** + * Parameters for deleting an asset from the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type BulkDeleteAssetsParams = { + ids: string[] +} diff --git a/src/types/api/asset/tag.ts b/src/types/api/asset/tag.ts new file mode 100644 index 00000000..e89a8c03 --- /dev/null +++ b/src/types/api/asset/tag.ts @@ -0,0 +1,59 @@ +/** + * An object representing an tag used by the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type AssetTag = { + /** + * Tag ID. + */ + id: string + + /** + * Tag name. + */ + name: string + + /** + * Tag creation date. + */ + created_at: number + + /** + * Tag last modification date. + */ + last_modified: number + + /** + * @internal + */ + uploader_id?: string + + /** + * Number of assets tagged with this tag. + */ + count?: number +} + +/** + * An object representing the result of querying tags from the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type GetAssetTagsResult = { items: AssetTag[] } + +/** + * Parameters for creating a tag in the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PostAssetTagParams = { + name: string +} + +/** + * Result of creating a tag in the Asset API. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PostAssetTagResult = AssetTag diff --git a/src/types/api/migration/document.ts b/src/types/api/migration/document.ts new file mode 100644 index 00000000..347854c2 --- /dev/null +++ b/src/types/api/migration/document.ts @@ -0,0 +1,84 @@ +import type { + PrismicDocument, + PrismicDocumentWithUID, +} from "../../value/document" + +/** + * An object representing the parameters required when creating a document + * through the Migration API. + * + * @typeParam TDocument - Type of the Prismic document to create. + * + * @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference} + */ +export type PostDocumentParams< + TDocument extends PrismicDocument = PrismicDocument, +> = + TDocument extends PrismicDocument + ? { + title: string + + type: TType + lang: TLang + alternate_language_id?: string + + tags?: string[] + data: TData | Record + } & (TDocument extends PrismicDocumentWithUID + ? { uid: TDocument["uid"] } + : { uid?: TDocument["uid"] }) + : never + +/** + * Result of creating a document with the Migration API. + * + * @typeParam TDocument - Type of the created Prismic document. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PostDocumentResult< + TDocument extends PrismicDocument = PrismicDocument, +> = + TDocument extends PrismicDocument + ? { + title: string + + id: string + type: TType + lang: TLang + } & (TDocument["uid"] extends string + ? { uid: TDocument["uid"] } + : { uid?: TDocument["uid"] }) + : never + +/** + * An object representing the parameters required when updating a document + * through the Migration API. + * + * @typeParam TDocument - Type of the Prismic document to update. + * + * @see Prismic Migration API technical reference: {@link https://prismic.io/docs/migration-api-technical-reference} + */ +export type PutDocumentParams< + TDocument extends PrismicDocument = PrismicDocument, +> = { + title?: string + + uid?: string + + tags?: string[] + // We don't need to infer the document type as above here because we don't + // need to pair the document type with the document data in put requests. + data: TDocument["data"] | Record +} + +/** + * Result of updating a document with the Migration API. + * + * @typeParam TDocument - Type of the updated Prismic document. + * + * @see Prismic Asset API technical reference: {@link https://prismic.io/docs/asset-api-technical-reference} + */ +export type PutDocumentResult< + TDocument extends PrismicDocument = PrismicDocument, +> = PostDocumentResult diff --git a/src/types/migration/Asset.ts b/src/types/migration/Asset.ts new file mode 100644 index 00000000..8aa928a9 --- /dev/null +++ b/src/types/migration/Asset.ts @@ -0,0 +1,156 @@ +import type { Asset } from "../api/asset/asset" +import type { FilledImageFieldImage } from "../value/image" +import type { EmptyLinkField } from "../value/link" +import type { LinkToMediaField } from "../value/linkToMedia" +import { type RTImageNode } from "../value/richText" + +import type { InjectMigrationSpecificTypes } from "./Document" + +/** + * An asset to be uploaded to Prismic media library. + */ +export type MigrationAssetConfig = { + /** + * ID the assets is indexed with on the migration instance. + * + * @remarks + * This property's value is not necessarily the same as the as the one in the + * `file` property. It is mainly used for deduplication within a `Migration` + * instance. + */ + id: string | URL | File | NonNullable[0]>[0] + + /** + * File to be uploaded as an asset. + */ + file: string | URL | File | NonNullable[0]>[0] + + /** + * Filename of the asset. + */ + filename: string + + /** + * Notes about the asset. Notes are private and only visible in Prismic media + * library. + */ + notes?: string + + /** + * Credits and copyright for the asset if any. + */ + credits?: string + + /** + * Alternate text for the asset. + */ + alt?: string + + /** + * Tags associated with the asset. + * + * @remarks + * Tags should be at least 3 characters long and 20 characters at most. + */ + tags?: string[] +} + +/** + * An image field in a migration. + */ +export type MigrationImage = + | PrismicMigrationAsset + | ({ + /** + * A reference to the migration asset used to resolve the image field's + * value. + */ + id: PrismicMigrationAsset + } & Record) + +/** + * A link to media field in a migration. + */ +export type MigrationLinkToMedia = Pick< + LinkToMediaField<"filled">, + "link_type" +> & + Partial< + Pick< + LinkToMediaField<"filled">, + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + "text" + > + > & { + /** + * A reference to the migration asset used to resolve the link to media + * field's value. + */ + id: PrismicMigrationAsset + } + +/** + * The minimum amount of information needed to represent a link to media field + * with the migration API. + */ +export type MigrationLinkToMediaField = + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + | Pick, "link_type" | "id" | "text"> + | EmptyLinkField<"Media"> + +/** + * A rich text image node in a migration. + */ +export type MigrationRTImageNode = InjectMigrationSpecificTypes< + Pick +> & { + /** + * A reference to the migration asset used to resolve the rich text image + * node's value. + */ + id: PrismicMigrationAsset +} + +/** + * A migration asset used with the Prismic Migration API. + */ +export class PrismicMigrationAsset { + /** + * Asset object from Prismic, available once created. + */ + asset?: Asset + + /** + * Configuration of the asset. + */ + config: MigrationAssetConfig + + /** + * The initial field value this migration field was created with. + */ + originalField?: + | FilledImageFieldImage + | LinkToMediaField<"filled"> + | RTImageNode + + /** + * Creates a migration asset used with the Prismic Migration API. + * + * @param config - Configuration of the asset. + * @param initialField - The initial field value if any. + * + * @returns A migration asset instance. + */ + constructor( + config: MigrationAssetConfig, + initialField?: + | FilledImageFieldImage + | LinkToMediaField<"filled"> + | RTImageNode, + ) { + this.config = config + this.originalField = initialField + } +} diff --git a/src/types/migration/ContentRelationship.ts b/src/types/migration/ContentRelationship.ts new file mode 100644 index 00000000..44255fc6 --- /dev/null +++ b/src/types/migration/ContentRelationship.ts @@ -0,0 +1,36 @@ +import type { FilledContentRelationshipField } from "../value/contentRelationship" +import type { PrismicDocument } from "../value/document" +import type { EmptyLinkField } from "../value/link" + +import type { PrismicMigrationDocument } from "./Document" + +type ValueOrThunk = T | (() => Promise | T) + +/** + * A content relationship field in a migration. + */ +export type MigrationContentRelationship< + TDocuments extends PrismicDocument = PrismicDocument, +> = + | ValueOrThunk | undefined> + | (Pick & + Partial< + Pick< + FilledContentRelationshipField, + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + "text" + > + > & { + id: ValueOrThunk< + TDocuments | PrismicMigrationDocument | undefined + > + }) + +/** + * The minimum amount of information needed to represent a content relationship + * field with the migration API. + */ +export type MigrationContentRelationshipField = + | Pick + | EmptyLinkField<"Document"> diff --git a/src/types/migration/Document.ts b/src/types/migration/Document.ts new file mode 100644 index 00000000..a82d0860 --- /dev/null +++ b/src/types/migration/Document.ts @@ -0,0 +1,157 @@ +import type { FilledContentRelationshipField } from "../value/contentRelationship" +import type { PrismicDocument, PrismicDocumentWithUID } from "../value/document" +import type { AnyOEmbed, EmbedField, OEmbedExtra } from "../value/embed" +import type { FilledImageFieldImage } from "../value/image" +import type { FilledLinkToMediaField } from "../value/linkToMedia" +import type { RTImageNode } from "../value/richText" +import type { SharedSlice } from "../value/sharedSlice" + +import type { + MigrationImage, + MigrationLinkToMedia, + MigrationRTImageNode, +} from "./Asset" +import type { MigrationContentRelationship } from "./ContentRelationship" + +/** + * A utility type that extends any fields in a record with their migration + * fields equivalent. + * + * @typeParam T - Type of the record to extend. + */ +export type InjectMigrationSpecificTypes = T extends RTImageNode + ? + | T + | (Omit & InjectMigrationSpecificTypes>) + | MigrationRTImageNode + | undefined + : T extends FilledImageFieldImage + ? T | MigrationImage | undefined + : T extends FilledLinkToMediaField + ? T | MigrationLinkToMedia | undefined + : T extends FilledContentRelationshipField + ? T | MigrationContentRelationship + : T extends EmbedField + ? T | Pick + : T extends SharedSlice + ? + | T + | InjectMigrationSpecificTypes< + Omit + > + : // eslint-disable-next-line @typescript-eslint/no-explicit-any + T extends Record + ? { [P in keyof T]: InjectMigrationSpecificTypes } + : T extends Array + ? Array> + : T + +/** + * A utility type that ties the type and data of a Prismic document, creating a + * strict union. + */ +type TiedDocumentTypeAndData = + TDocument extends PrismicDocument + ? { + /** + * Type of the document. + */ + type: TType + + /** + * Data contained in the document. + */ + data: InjectMigrationSpecificTypes + } & (TDocument extends PrismicDocumentWithUID + ? Pick + : Partial>) + : never + +/** + * A pending Prismic document to be created with the Migration API. + * + * @typeParam TDocument - Type of the Prismic document. + */ +export type PendingPrismicDocument< + TDocument extends PrismicDocument = PrismicDocument, +> = Pick & + Partial> & + TiedDocumentTypeAndData + +/** + * An existing Prismic document to be updated with the Migration API. + * + * @typeParam TDocument - Type of the Prismic document. + */ +export type ExistingPrismicDocument< + TDocument extends PrismicDocument = PrismicDocument, +> = Omit & + TiedDocumentTypeAndData + +/** + * A Prismic document to be sent to the Migration API. + * + * @typeParam TDocument - Type of the Prismic document. + */ +export type MigrationDocument< + TDocument extends PrismicDocument = PrismicDocument, +> = PendingPrismicDocument | ExistingPrismicDocument + +/** + * A Prismic migration document instance. + * + * @typeParam TDocument - Type of the Prismic document. + */ +export class PrismicMigrationDocument< + TDocument extends PrismicDocument = PrismicDocument, +> { + /** + * The document to be sent to the Migration API. + */ + document: MigrationDocument & Partial> + + /** + * The name of the document displayed in the editor. + */ + title?: string + + /** + * The link to the master language document to relate the document to if any. + */ + masterLanguageDocument?: MigrationContentRelationship + + /** + * Original Prismic document when the migration document came from another + * Prismic repository. + * + * @remarks + * When migrating a document from another repository, one might want to alter + * it with migration specific types, hence accepting an + * `ExistingPrismicDocument` instead of a regular `PrismicDocument`. + */ + originalPrismicDocument?: ExistingPrismicDocument + + /** + * Creates a Prismic migration document instance. + * + * @param document - The document to be sent to the Migration API. + * @param title - The name of the document displayed in the editor. + * @param params - Parameters to create/update the document with on the + * Migration API. + * + * @returns A Prismic migration document instance. + */ + constructor( + document: MigrationDocument, + title?: string, + params?: { + masterLanguageDocument?: MigrationContentRelationship + originalPrismicDocument?: ExistingPrismicDocument + }, + ) { + this.document = document + this.title = title + this.masterLanguageDocument = params?.masterLanguageDocument + this.originalPrismicDocument = params?.originalPrismicDocument + } +} diff --git a/test/__snapshots__/writeClient-migrate-patch-contentRelationship.test.ts.snap b/test/__snapshots__/writeClient-migrate-patch-contentRelationship.test.ts.snap new file mode 100644 index 00000000..337e410a --- /dev/null +++ b/test/__snapshots__/writeClient-migrate-patch-contentRelationship.test.ts.snap @@ -0,0 +1,1065 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`patches content relationship fields (from Prismic) > broken > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "_____broken_____", + "isBroken": true, + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > broken > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "_____broken_____", + "isBroken": true, + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "_____broken_____", + "isBroken": true, + "link_type": "Document", + }, + "group": [ + { + "field": { + "id": "_____broken_____", + "isBroken": true, + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > broken > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "_____broken_____", + "isBroken": true, + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "_____broken_____", + "isBroken": true, + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > broken > static zone 1`] = ` +{ + "field": { + "id": "_____broken_____", + "isBroken": true, + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields (from Prismic) > simple > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > simple > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + }, + "group": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > simple > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > simple > static zone 1`] = ` +{ + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields (from Prismic) > withText > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + "text": "foo", + }, + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > withText > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + "text": "foo", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + "text": "foo", + }, + "group": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + "text": "foo", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > withText > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + "text": "foo", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + "text": "foo", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields (from Prismic) > withText > static zone 1`] = ` +{ + "field": { + "id": "other.id-fromPrismic", + "link_type": "Document", + "text": "foo", + }, +} +`; + +exports[`patches content relationship fields > existing > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields > existing > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > existing > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > existing > static zone 1`] = ` +{ + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields > existingLongForm > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields > existingLongForm > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > existingLongForm > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > existingLongForm > static zone 1`] = ` +{ + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields > existingLongFormWithText > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + "text": "foo", + }, + }, + ], +} +`; + +exports[`patches content relationship fields > existingLongFormWithText > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + "text": "foo", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + "text": "foo", + }, + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + "text": "foo", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > existingLongFormWithText > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + "text": "foo", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + "text": "foo", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > existingLongFormWithText > static zone 1`] = ` +{ + "field": { + "id": "other.id-existing", + "link_type": "Document", + "text": "foo", + }, +} +`; + +exports[`patches content relationship fields > lazyExisting > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields > lazyExisting > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + "group": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > lazyExisting > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > lazyExisting > static zone 1`] = ` +{ + "field": { + "id": "other.id-existing", + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields > lazyOtherCreate > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields > lazyOtherCreate > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + "group": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > lazyOtherCreate > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > lazyOtherCreate > static zone 1`] = ` +{ + "field": { + "id": "other.id-create", + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields > lazyOtherCreateMissingID > group 1`] = ` +{ + "group": [ + { + "field": { + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields > lazyOtherCreateMissingID > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "link_type": "Document", + }, + "group": [ + { + "field": { + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > lazyOtherCreateMissingID > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > lazyOtherCreateMissingID > static zone 1`] = ` +{ + "field": { + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields > otherCreate > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], +} +`; + +exports[`patches content relationship fields > otherCreate > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + "group": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > otherCreate > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + ], + "primary": { + "field": { + "id": "other.id-create", + "link_type": "Document", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > otherCreate > static zone 1`] = ` +{ + "field": { + "id": "other.id-create", + "link_type": "Document", + }, +} +`; + +exports[`patches content relationship fields > richTextLinkNode > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "other.id-existing", + "link_type": "Document", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], +} +`; + +exports[`patches content relationship fields > richTextLinkNode > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "other.id-existing", + "link_type": "Document", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "other.id-existing", + "link_type": "Document", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "other.id-existing", + "link_type": "Document", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches content relationship fields > richTextLinkNode > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "other.id-existing", + "link_type": "Document", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "other.id-existing", + "link_type": "Document", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches content relationship fields > richTextLinkNode > static zone 1`] = ` +{ + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "other.id-existing", + "link_type": "Document", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], +} +`; diff --git a/test/__snapshots__/writeClient-migrate-patch-image.test.ts.snap b/test/__snapshots__/writeClient-migrate-patch-image.test.ts.snap new file mode 100644 index 00000000..3662a201 --- /dev/null +++ b/test/__snapshots__/writeClient-migrate-patch-image.test.ts.snap @@ -0,0 +1,1898 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`patches image fields (from Prismic) > empty > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": null, + "id": null, + "url": null, + }, + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > empty > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": null, + "id": null, + "url": null, + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": null, + "id": null, + "url": null, + }, + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": null, + "id": null, + "url": null, + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > empty > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": null, + "id": null, + "url": null, + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": null, + "id": null, + "url": null, + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > empty > static zone 1`] = ` +{ + "field": { + "alt": null, + "copyright": null, + "dimensions": null, + "id": null, + "url": null, + }, +} +`; + +exports[`patches image fields (from Prismic) > simple > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": "Morbi tincidunt ornare massa eget egestas purus viverra accumsan in nisl nisi scelerisque eu", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#fc26fe", + "x": 1643, + "y": -110, + "zoom": 1.3701173345023108, + }, + "id": "d0985c09900", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724", + }, + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > simple > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": "Nam libero justo laoreet sit amet cursus sit amet dictum sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d9ed25", + "x": 1472, + "y": -1646, + "zoom": 1.0945668828428181, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5", + }, + }, + ], + "primary": { + "field": { + "alt": "Massa massa ultricies mi quis hendrerit dolor magna eget est lorem ipsum dolor sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#04f4f5", + "x": 27, + "y": -816, + "zoom": 1.9856937117841313, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5", + }, + "group": [ + { + "field": { + "alt": "Egestas maecenas pharetra convallis posuere morbi leo urna", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d42f5c", + "x": 1778, + "y": -875, + "zoom": 1.6943119181539659, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > simple > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": "Molestie lorem ipsum dolor sit amet consectetur adipiscing elit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#ff72de", + "x": 329, + "y": -450, + "zoom": 1.9747006336633817, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg", + }, + }, + ], + "primary": { + "field": { + "alt": "Tincidunt vitae semper quis lectus", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#860cba", + "x": -1925, + "y": -2408, + "zoom": 1.4001176423393464, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > simple > static zone 1`] = ` +{ + "field": { + "alt": "Velit laoreet id donec ultrices tincidunt arcu non sodales neque sodales ut etiam sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#1d17b0", + "x": -1859, + "y": 1185, + "zoom": 1.2989647419422317, + }, + "id": "a95cc61c373", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg", + }, +} +`; + +exports[`patches image fields (from Prismic) > withSpecialTypeThumbnail > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": "Morbi tincidunt ornare massa eget egestas purus viverra accumsan in nisl nisi scelerisque eu", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#fc26fe", + "x": 1643, + "y": -110, + "zoom": 1.3701173345023108, + }, + "id": "d0985c09900", + "type": { + "alt": "Morbi tincidunt ornare massa eget egestas purus viverra accumsan in nisl nisi scelerisque eu", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#fc26fe", + "x": 1643, + "y": -110, + "zoom": 1.3701173345023108, + }, + "id": "d0985c09900", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?some=query", + }, + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withSpecialTypeThumbnail > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": "Nam libero justo laoreet sit amet cursus sit amet dictum sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d9ed25", + "x": 1472, + "y": -1646, + "zoom": 1.0945668828428181, + }, + "id": "b35eaa8ebee", + "type": { + "alt": "Nam libero justo laoreet sit amet cursus sit amet dictum sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d9ed25", + "x": 1472, + "y": -1646, + "zoom": 1.0945668828428181, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + }, + ], + "primary": { + "field": { + "alt": "Massa massa ultricies mi quis hendrerit dolor magna eget est lorem ipsum dolor sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#04f4f5", + "x": 27, + "y": -816, + "zoom": 1.9856937117841313, + }, + "id": "b35eaa8ebee", + "type": { + "alt": "Massa massa ultricies mi quis hendrerit dolor magna eget est lorem ipsum dolor sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#04f4f5", + "x": 27, + "y": -816, + "zoom": 1.9856937117841313, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + "group": [ + { + "field": { + "alt": "Egestas maecenas pharetra convallis posuere morbi leo urna", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d42f5c", + "x": 1778, + "y": -875, + "zoom": 1.6943119181539659, + }, + "id": "b35eaa8ebee", + "type": { + "alt": "Egestas maecenas pharetra convallis posuere morbi leo urna", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d42f5c", + "x": 1778, + "y": -875, + "zoom": 1.6943119181539659, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withSpecialTypeThumbnail > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": "Molestie lorem ipsum dolor sit amet consectetur adipiscing elit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#ff72de", + "x": 329, + "y": -450, + "zoom": 1.9747006336633817, + }, + "id": "07a35bc5aa2", + "type": { + "alt": "Molestie lorem ipsum dolor sit amet consectetur adipiscing elit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#ff72de", + "x": 329, + "y": -450, + "zoom": 1.9747006336633817, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, + }, + ], + "primary": { + "field": { + "alt": "Tincidunt vitae semper quis lectus", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#860cba", + "x": -1925, + "y": -2408, + "zoom": 1.4001176423393464, + }, + "id": "07a35bc5aa2", + "type": { + "alt": "Tincidunt vitae semper quis lectus", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#860cba", + "x": -1925, + "y": -2408, + "zoom": 1.4001176423393464, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withSpecialTypeThumbnail > static zone 1`] = ` +{ + "field": { + "alt": "Velit laoreet id donec ultrices tincidunt arcu non sodales neque sodales ut etiam sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#1d17b0", + "x": -1859, + "y": 1185, + "zoom": 1.2989647419422317, + }, + "id": "a95cc61c373", + "type": { + "alt": "Velit laoreet id donec ultrices tincidunt arcu non sodales neque sodales ut etiam sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#1d17b0", + "x": -1859, + "y": 1185, + "zoom": 1.2989647419422317, + }, + "id": "a95cc61c373", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, +} +`; + +exports[`patches image fields (from Prismic) > withThumbnails > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": "Morbi tincidunt ornare massa eget egestas purus viverra accumsan in nisl nisi scelerisque eu", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#fc26fe", + "x": 1643, + "y": -110, + "zoom": 1.3701173345023108, + }, + "id": "d0985c09900", + "square": { + "alt": "Morbi tincidunt ornare massa eget egestas purus viverra accumsan in nisl nisi scelerisque eu", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#fc26fe", + "x": 1643, + "y": -110, + "zoom": 1.3701173345023108, + }, + "id": "d0985c09900", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?some=query", + }, + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withThumbnails > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": "Nam libero justo laoreet sit amet cursus sit amet dictum sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d9ed25", + "x": 1472, + "y": -1646, + "zoom": 1.0945668828428181, + }, + "id": "b35eaa8ebee", + "square": { + "alt": "Nam libero justo laoreet sit amet cursus sit amet dictum sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d9ed25", + "x": 1472, + "y": -1646, + "zoom": 1.0945668828428181, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + }, + ], + "primary": { + "field": { + "alt": "Massa massa ultricies mi quis hendrerit dolor magna eget est lorem ipsum dolor sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#04f4f5", + "x": 27, + "y": -816, + "zoom": 1.9856937117841313, + }, + "id": "b35eaa8ebee", + "square": { + "alt": "Massa massa ultricies mi quis hendrerit dolor magna eget est lorem ipsum dolor sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#04f4f5", + "x": 27, + "y": -816, + "zoom": 1.9856937117841313, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + "group": [ + { + "field": { + "alt": "Egestas maecenas pharetra convallis posuere morbi leo urna", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d42f5c", + "x": 1778, + "y": -875, + "zoom": 1.6943119181539659, + }, + "id": "b35eaa8ebee", + "square": { + "alt": "Egestas maecenas pharetra convallis posuere morbi leo urna", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d42f5c", + "x": 1778, + "y": -875, + "zoom": 1.6943119181539659, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withThumbnails > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": "Molestie lorem ipsum dolor sit amet consectetur adipiscing elit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#ff72de", + "x": 329, + "y": -450, + "zoom": 1.9747006336633817, + }, + "id": "07a35bc5aa2", + "square": { + "alt": "Molestie lorem ipsum dolor sit amet consectetur adipiscing elit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#ff72de", + "x": 329, + "y": -450, + "zoom": 1.9747006336633817, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, + }, + ], + "primary": { + "field": { + "alt": "Tincidunt vitae semper quis lectus", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#860cba", + "x": -1925, + "y": -2408, + "zoom": 1.4001176423393464, + }, + "id": "07a35bc5aa2", + "square": { + "alt": "Tincidunt vitae semper quis lectus", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#860cba", + "x": -1925, + "y": -2408, + "zoom": 1.4001176423393464, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withThumbnails > static zone 1`] = ` +{ + "field": { + "alt": "Velit laoreet id donec ultrices tincidunt arcu non sodales neque sodales ut etiam sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#1d17b0", + "x": -1859, + "y": 1185, + "zoom": 1.2989647419422317, + }, + "id": "a95cc61c373", + "square": { + "alt": "Velit laoreet id donec ultrices tincidunt arcu non sodales neque sodales ut etiam sit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#1d17b0", + "x": -1859, + "y": 1185, + "zoom": 1.2989647419422317, + }, + "id": "a95cc61c373", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, +} +`; + +exports[`patches image fields (from Prismic) > withThumbnailsNoAlt > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#fc26fe", + "x": 1643, + "y": -110, + "zoom": 1.3701173345023108, + }, + "id": "d0985c09900", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#fc26fe", + "x": 1643, + "y": -110, + "zoom": 1.3701173345023108, + }, + "id": "d0985c09900", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?some=query", + }, + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withThumbnailsNoAlt > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d9ed25", + "x": 1472, + "y": -1646, + "zoom": 1.0945668828428181, + }, + "id": "b35eaa8ebee", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d9ed25", + "x": 1472, + "y": -1646, + "zoom": 1.0945668828428181, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#04f4f5", + "x": 27, + "y": -816, + "zoom": 1.9856937117841313, + }, + "id": "b35eaa8ebee", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#04f4f5", + "x": 27, + "y": -816, + "zoom": 1.9856937117841313, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d42f5c", + "x": 1778, + "y": -875, + "zoom": 1.6943119181539659, + }, + "id": "b35eaa8ebee", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#d42f5c", + "x": 1778, + "y": -875, + "zoom": 1.6943119181539659, + }, + "id": "b35eaa8ebee", + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=other&query=params", + }, + "url": "https://images.unsplash.com/photo-1418065460487-3e41a6c84dc5?some=query", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withThumbnailsNoAlt > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#ff72de", + "x": 329, + "y": -450, + "zoom": 1.9747006336633817, + }, + "id": "07a35bc5aa2", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#ff72de", + "x": 329, + "y": -450, + "zoom": 1.9747006336633817, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#860cba", + "x": -1925, + "y": -2408, + "zoom": 1.4001176423393464, + }, + "id": "07a35bc5aa2", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#860cba", + "x": -1925, + "y": -2408, + "zoom": 1.4001176423393464, + }, + "id": "07a35bc5aa2", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields (from Prismic) > withThumbnailsNoAlt > static zone 1`] = ` +{ + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#1d17b0", + "x": -1859, + "y": 1185, + "zoom": 1.2989647419422317, + }, + "id": "a95cc61c373", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#1d17b0", + "x": -1859, + "y": 1185, + "zoom": 1.2989647419422317, + }, + "id": "a95cc61c373", + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=other&query=params", + }, + "url": "https://images.unsplash.com/reserve/HgZuGu3gSD6db21T3lxm_San%20Zenone.jpg?some=query", + }, +} +`; + +exports[`patches image fields > existing > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "url": "https://images.unsplash.com/photo-1470770903676-69b98201ea1c?w=4554&h=3036&fit=crop", + }, + }, + ], +} +`; + +exports[`patches image fields > existing > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + }, + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields > existing > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields > existing > static zone 1`] = ` +{ + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "url": "https://images.unsplash.com/photo-1604537529428-15bcbeecfe4d?w=4240&h=2832&fit=crop", + }, +} +`; + +exports[`patches image fields > new > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "fdd322ca9d5", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + }, + ], +} +`; + +exports[`patches image fields > new > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields > new > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields > new > static zone 1`] = ` +{ + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "bad670dad77", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, +} +`; + +exports[`patches image fields > newLongForm > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "fdd322ca9d5", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + }, + ], +} +`; + +exports[`patches image fields > newLongForm > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields > newLongForm > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields > newLongForm > static zone 1`] = ` +{ + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "bad670dad77", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, +} +`; + +exports[`patches image fields > newThumbnails > group 1`] = ` +{ + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "fdd322ca9d5", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "fdd322ca9d5", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + }, + ], +} +`; + +exports[`patches image fields > newThumbnails > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + "group": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "c0eddbd0255", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff?w=5616&h=3744&fit=crop", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches image fields > newThumbnails > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + }, + ], + "primary": { + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "82beb55ec2f", + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + "url": "https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?w=7372&h=4392&fit=crop", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches image fields > newThumbnails > static zone 1`] = ` +{ + "field": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "bad670dad77", + "square": { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "bad670dad77", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, +} +`; diff --git a/test/__snapshots__/writeClient-migrate-patch-linkToMedia.test.ts.snap b/test/__snapshots__/writeClient-migrate-patch-linkToMedia.test.ts.snap new file mode 100644 index 00000000..9275be7f --- /dev/null +++ b/test/__snapshots__/writeClient-migrate-patch-linkToMedia.test.ts.snap @@ -0,0 +1,1219 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`patches link to media fields (from Prismic) > inRichText > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "fc26fe8d098", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], +} +`; + +exports[`patches link to media fields (from Prismic) > inRichText > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "961adcf1f5d", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "961adcf1f5d", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "961adcf1f5d", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields (from Prismic) > inRichText > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "54f4a69ff72", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "54f4a69ff72", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields (from Prismic) > inRichText > static zone 1`] = ` +{ + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "1d17b04a95c", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], +} +`; + +exports[`patches link to media fields (from Prismic) > simple > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "fc26fe8d098", + "link_type": "Media", + }, + }, + ], +} +`; + +exports[`patches link to media fields (from Prismic) > simple > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "961adcf1f5d", + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "id": "961adcf1f5d", + "link_type": "Media", + }, + "group": [ + { + "field": { + "id": "961adcf1f5d", + "link_type": "Media", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields (from Prismic) > simple > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "54f4a69ff72", + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "id": "54f4a69ff72", + "link_type": "Media", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields (from Prismic) > simple > static zone 1`] = ` +{ + "field": { + "id": "1d17b04a95c", + "link_type": "Media", + }, +} +`; + +exports[`patches link to media fields > empty > group 1`] = ` +{ + "group": [ + { + "field": { + "link_type": "Media", + }, + }, + ], +} +`; + +exports[`patches link to media fields > empty > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "link_type": "Media", + }, + "group": [ + { + "field": { + "link_type": "Media", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields > empty > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "link_type": "Media", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields > empty > static zone 1`] = ` +{ + "field": { + "link_type": "Media", + }, +} +`; + +exports[`patches link to media fields > existing > group 1`] = ` +{ + "group": [ + { + "field": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1470770903676-69b98201ea1c?w=4554&h=3036&fit=crop", + "width": "1", + }, + }, + ], +} +`; + +exports[`patches link to media fields > existing > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + "width": "1", + }, + }, + ], + "primary": { + "field": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + "width": "1", + }, + "group": [ + { + "field": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + "width": "1", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields > existing > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + "width": "1", + }, + }, + ], + "primary": { + "field": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + "width": "1", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields > existing > static zone 1`] = ` +{ + "field": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1604537529428-15bcbeecfe4d?w=4240&h=2832&fit=crop", + "width": "1", + }, +} +`; + +exports[`patches link to media fields > new > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "fdd322ca9d5", + "link_type": "Media", + }, + }, + ], +} +`; + +exports[`patches link to media fields > new > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + "group": [ + { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields > new > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "82beb55ec2f", + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "id": "82beb55ec2f", + "link_type": "Media", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields > new > static zone 1`] = ` +{ + "field": { + "id": "bad670dad77", + "link_type": "Media", + }, +} +`; + +exports[`patches link to media fields > newNonImage > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "fdd322ca9d5", + "link_type": "Media", + }, + }, + ], +} +`; + +exports[`patches link to media fields > newNonImage > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + "group": [ + { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields > newNonImage > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "82beb55ec2f", + "link_type": "Media", + }, + }, + ], + "primary": { + "field": { + "id": "82beb55ec2f", + "link_type": "Media", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields > newNonImage > static zone 1`] = ` +{ + "field": { + "id": "bad670dad77", + "link_type": "Media", + }, +} +`; + +exports[`patches link to media fields > newWithText > group 1`] = ` +{ + "group": [ + { + "field": { + "id": "fdd322ca9d5", + "link_type": "Media", + "text": "foo", + }, + }, + ], +} +`; + +exports[`patches link to media fields > newWithText > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + "text": "foo", + }, + }, + ], + "primary": { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + "text": "foo", + }, + "group": [ + { + "field": { + "id": "c0eddbd0255", + "link_type": "Media", + "text": "foo", + }, + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields > newWithText > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": { + "id": "82beb55ec2f", + "link_type": "Media", + "text": "foo", + }, + }, + ], + "primary": { + "field": { + "id": "82beb55ec2f", + "link_type": "Media", + "text": "foo", + }, + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields > newWithText > static zone 1`] = ` +{ + "field": { + "id": "bad670dad77", + "link_type": "Media", + "text": "foo", + }, +} +`; + +exports[`patches link to media fields > richTextExisting > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1470770903676-69b98201ea1c?w=4554&h=3036&fit=crop", + "width": "1", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], +} +`; + +exports[`patches link to media fields > richTextExisting > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + "width": "1", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + "width": "1", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + "width": "1", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields > richTextExisting > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + "width": "1", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + "width": "1", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields > richTextExisting > static zone 1`] = ` +{ + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "height": "1", + "id": "asset.id-existing", + "kind": "image", + "link_type": "Media", + "name": "default.jpg", + "size": "1", + "url": "https://images.unsplash.com/photo-1604537529428-15bcbeecfe4d?w=4240&h=2832&fit=crop", + "width": "1", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], +} +`; + +exports[`patches link to media fields > richTextNew > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "fdd322ca9d5", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], +} +`; + +exports[`patches link to media fields > richTextNew > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + "group": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "c0eddbd0255", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches link to media fields > richTextNew > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "82beb55ec2f", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "82beb55ec2f", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches link to media fields > richTextNew > static zone 1`] = ` +{ + "field": [ + { + "spans": [ + { + "end": 5, + "start": 0, + "type": "strong", + }, + { + "data": { + "id": "bad670dad77", + "link_type": "Media", + }, + "end": 5, + "start": 0, + "type": "hyperlink", + }, + ], + "text": "lorem", + "type": "paragraph", + }, + ], +} +`; diff --git a/test/__snapshots__/writeClient-migrate-patch-rtImageNode.test.ts.snap b/test/__snapshots__/writeClient-migrate-patch-rtImageNode.test.ts.snap new file mode 100644 index 00000000..0aa39cff --- /dev/null +++ b/test/__snapshots__/writeClient-migrate-patch-rtImageNode.test.ts.snap @@ -0,0 +1,1177 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`patches rich text image nodes (from Prismic) > simple > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [], + "text": "Pulvinar elementum integer enim neque volutpat ac tincidunt vitae semper quis. Gravida hendrerit lectus a molestie lorem ipsum dolor sit amet. Mauris sit amet massa vitae tortor condimentum lacinia. Blandit volutpat maecenas volutpat blandit aliquam. Massa eget egestas purus viverra accumsan in nisl nisi scelerisque. Imperdiet dui accumsan sit amet nulla facilisi morbi tempus iaculis.", + "type": "paragraph", + }, + { + "alt": "Phasellus faucibus scelerisque eleifend donec pretium vulputate", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#14a045", + "x": -1057, + "y": 1363, + "zoom": 1.3020761265191427, + }, + "id": "097ac4a7efe", + "type": "image", + "url": "https://images.unsplash.com/photo-1441974231531-c6227db76b6e", + }, + ], + }, + ], +} +`; + +exports[`patches rich text image nodes (from Prismic) > simple > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Ultrices In Iaculis Nunc Sed Augue Lacus Viverra Vitae Congue Eu", + "type": "heading4", + }, + { + "alt": "Dolor morbi non arcu risus quis varius quam quisque id diam vel", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#073a8e", + "x": -531, + "y": 1549, + "zoom": 1.1496011393958705, + }, + "id": "9378bc2e4da", + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Egestas maecenas pharetra convallis posuere morbi leo urna molestie at. Cras fermentum odio eu feugiat pretium. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus imperdiet nulla malesuada. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh. Eleifend donec pretium vulputate sapien.", + "type": "paragraph", + }, + { + "alt": "A scelerisque purus semper eget duis at tellus at urna condimentum mattis", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#53af6d", + "x": 1608, + "y": -790, + "zoom": 1.8290852631509968, + }, + "id": "9378bc2e4da", + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1", + }, + ], + "group": [ + { + "field": [ + { + "spans": [], + "text": "Consectetur adipiscing elit duis tristique sollicitudin nibh sit amet commodo", + "type": "o-list-item", + }, + { + "alt": "Lorem mollis aliquam ut porttitor leo a", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#35eaa8", + "x": -757, + "y": -123, + "zoom": 1.2194779259663722, + }, + "id": "9378bc2e4da", + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches rich text image nodes (from Prismic) > simple > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Auctor neque vitae tempus quam. At ultrices mi tempus imperdiet nulla malesuada pellentesque elit eget gravida cum.", + "type": "paragraph", + }, + { + "alt": "Sed id semper risus in hendrerit gravida rutrum", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#104dde", + "x": -1621, + "y": -870, + "zoom": 1.7278759511485409, + }, + "id": "2003a644c30", + "type": "image", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Pharetra et ultrices neque ornare aenean euismod. Mauris sit amet massa vitae tortor condimentum lacinia quis vel eros donec ac.", + "type": "preformatted", + }, + { + "alt": "Vitae et leo duis ut diam quam nulla porttitor", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#cc54f4", + "x": 637, + "y": -1697, + "zoom": 1.1481742186351154, + }, + "id": "2003a644c30", + "type": "image", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches rich text image nodes (from Prismic) > simple > static zone 1`] = ` +{ + "field": [ + { + "spans": [], + "text": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis. Tincidunt praesent semper feugiat nibh sed pulvinar proin gravida hendrerit lectus a.", + "type": "paragraph", + }, + { + "alt": "Adipiscing bibendum est ultricies integer quis auctor elit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#b943c8", + "x": 592, + "y": 260, + "zoom": 1.3971999251277185, + }, + "id": "b5df4522c1a", + "type": "image", + "url": "https://images.unsplash.com/photo-1504198266287-1659872e6590", + }, + ], +} +`; + +exports[`patches rich text image nodes (from Prismic) > withLinkTo > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [], + "text": "Pulvinar elementum integer enim neque volutpat ac tincidunt vitae semper quis. Gravida hendrerit lectus a molestie lorem ipsum dolor sit amet. Mauris sit amet massa vitae tortor condimentum lacinia. Blandit volutpat maecenas volutpat blandit aliquam. Massa eget egestas purus viverra accumsan in nisl nisi scelerisque. Imperdiet dui accumsan sit amet nulla facilisi morbi tempus iaculis.", + "type": "paragraph", + }, + { + "alt": "Phasellus faucibus scelerisque eleifend donec pretium vulputate", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#14a045", + "x": -1057, + "y": 1363, + "zoom": 1.3020761265191427, + }, + "id": "097ac4a7efe", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1441974231531-c6227db76b6e", + }, + ], + }, + ], +} +`; + +exports[`patches rich text image nodes (from Prismic) > withLinkTo > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Ultrices In Iaculis Nunc Sed Augue Lacus Viverra Vitae Congue Eu", + "type": "heading4", + }, + { + "alt": "Dolor morbi non arcu risus quis varius quam quisque id diam vel", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#073a8e", + "x": -531, + "y": 1549, + "zoom": 1.1496011393958705, + }, + "id": "9378bc2e4da", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Egestas maecenas pharetra convallis posuere morbi leo urna molestie at. Cras fermentum odio eu feugiat pretium. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus imperdiet nulla malesuada. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh. Eleifend donec pretium vulputate sapien.", + "type": "paragraph", + }, + { + "alt": "A scelerisque purus semper eget duis at tellus at urna condimentum mattis", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#53af6d", + "x": 1608, + "y": -790, + "zoom": 1.8290852631509968, + }, + "id": "9378bc2e4da", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1", + }, + ], + "group": [ + { + "field": [ + { + "spans": [], + "text": "Consectetur adipiscing elit duis tristique sollicitudin nibh sit amet commodo", + "type": "o-list-item", + }, + { + "alt": "Lorem mollis aliquam ut porttitor leo a", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#35eaa8", + "x": -757, + "y": -123, + "zoom": 1.2194779259663722, + }, + "id": "9378bc2e4da", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches rich text image nodes (from Prismic) > withLinkTo > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Auctor neque vitae tempus quam. At ultrices mi tempus imperdiet nulla malesuada pellentesque elit eget gravida cum.", + "type": "paragraph", + }, + { + "alt": "Sed id semper risus in hendrerit gravida rutrum", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#104dde", + "x": -1621, + "y": -870, + "zoom": 1.7278759511485409, + }, + "id": "2003a644c30", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Pharetra et ultrices neque ornare aenean euismod. Mauris sit amet massa vitae tortor condimentum lacinia quis vel eros donec ac.", + "type": "preformatted", + }, + { + "alt": "Vitae et leo duis ut diam quam nulla porttitor", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#cc54f4", + "x": 637, + "y": -1697, + "zoom": 1.1481742186351154, + }, + "id": "2003a644c30", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1426604966848-d7adac402bff", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches rich text image nodes (from Prismic) > withLinkTo > static zone 1`] = ` +{ + "field": [ + { + "spans": [], + "text": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis. Tincidunt praesent semper feugiat nibh sed pulvinar proin gravida hendrerit lectus a.", + "type": "paragraph", + }, + { + "alt": "Adipiscing bibendum est ultricies integer quis auctor elit", + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "#b943c8", + "x": 592, + "y": 260, + "zoom": 1.3971999251277185, + }, + "id": "b5df4522c1a", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1504198266287-1659872e6590", + }, + ], +} +`; + +exports[`patches rich text image nodes > existing > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [], + "text": "Pulvinar elementum integer enim neque volutpat ac tincidunt vitae semper quis. Gravida hendrerit lectus a molestie lorem ipsum dolor sit amet. Mauris sit amet massa vitae tortor condimentum lacinia. Blandit volutpat maecenas volutpat blandit aliquam. Massa eget egestas purus viverra accumsan in nisl nisi scelerisque. Imperdiet dui accumsan sit amet nulla facilisi morbi tempus iaculis.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "type": "image", + "url": "https://images.unsplash.com/photo-1470770903676-69b98201ea1c?w=4554&h=3036&fit=crop", + }, + ], + }, + ], +} +`; + +exports[`patches rich text image nodes > existing > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Diam", + "type": "heading4", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "type": "image", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Egestas maecenas pharetra convallis posuere morbi leo urna molestie at. Cras fermentum odio eu feugiat pretium. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus imperdiet nulla malesuada. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh. Eleifend donec pretium vulputate sapien.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "type": "image", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + }, + ], + "group": [ + { + "field": [ + { + "spans": [], + "text": "Quis Enim Lobortis", + "type": "heading2", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "type": "image", + "url": "https://images.unsplash.com/photo-1504567961542-e24d9439a724?w=4608&h=3456&fit=crop", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches rich text image nodes > existing > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Ultrices dui sapien eget mi proin sed libero enim sed. Diam ut venenatis tellus in metus vulputate eu scelerisque felis imperdiet. Sed nisi lacus sed viverra tellus in hac habitasse. Eget mi proin sed libero enim sed faucibus turpis. Pellentesque id nibh tortor id aliquet. Aliquam sem et tortor consequat id porta nibh venenatis cras sed felis.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "type": "image", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Pharetra et ultrices neque ornare aenean euismod. Mauris sit amet massa vitae tortor condimentum lacinia quis vel eros donec ac.", + "type": "preformatted", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "type": "image", + "url": "https://images.unsplash.com/photo-1587502537745-84b86da1204f?w=6550&h=4367&fit=crop", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches rich text image nodes > existing > static zone 1`] = ` +{ + "field": [ + { + "spans": [], + "text": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis. Tincidunt praesent semper feugiat nibh sed pulvinar proin gravida hendrerit lectus a.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "asset.id-existing", + "type": "image", + "url": "https://images.unsplash.com/photo-1604537529428-15bcbeecfe4d?w=4240&h=2832&fit=crop", + }, + ], +} +`; + +exports[`patches rich text image nodes > new > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [], + "text": "Pulvinar elementum integer enim neque volutpat ac tincidunt vitae semper quis. Gravida hendrerit lectus a molestie lorem ipsum dolor sit amet. Mauris sit amet massa vitae tortor condimentum lacinia. Blandit volutpat maecenas volutpat blandit aliquam. Massa eget egestas purus viverra accumsan in nisl nisi scelerisque. Imperdiet dui accumsan sit amet nulla facilisi morbi tempus iaculis.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "26fe8d0985c", + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + }, + ], +} +`; + +exports[`patches rich text image nodes > new > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Diam", + "type": "heading4", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "f7b961adcf1", + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Egestas maecenas pharetra convallis posuere morbi leo urna molestie at. Cras fermentum odio eu feugiat pretium. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus imperdiet nulla malesuada. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh. Eleifend donec pretium vulputate sapien.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "f7b961adcf1", + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + "group": [ + { + "field": [ + { + "spans": [], + "text": "Quis Enim Lobortis", + "type": "heading2", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "f7b961adcf1", + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches rich text image nodes > new > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Ultrices dui sapien eget mi proin sed libero enim sed. Diam ut venenatis tellus in metus vulputate eu scelerisque felis imperdiet. Sed nisi lacus sed viverra tellus in hac habitasse. Eget mi proin sed libero enim sed faucibus turpis. Pellentesque id nibh tortor id aliquet. Aliquam sem et tortor consequat id porta nibh venenatis cras sed felis.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "54f4a69ff72", + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=2560&h=1440&fit=crop", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Pharetra et ultrices neque ornare aenean euismod. Mauris sit amet massa vitae tortor condimentum lacinia quis vel eros donec ac.", + "type": "preformatted", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "54f4a69ff72", + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=2560&h=1440&fit=crop", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches rich text image nodes > new > static zone 1`] = ` +{ + "field": [ + { + "spans": [], + "text": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis. Tincidunt praesent semper feugiat nibh sed pulvinar proin gravida hendrerit lectus a.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "ab1d17b04a9", + "type": "image", + "url": "https://images.unsplash.com/photo-1553531384-397c80973a0b?w=4335&h=6502&fit=crop", + }, + ], +} +`; + +exports[`patches rich text image nodes > newLinkTo > group 1`] = ` +{ + "group": [ + { + "field": [ + { + "spans": [], + "text": "Pulvinar elementum integer enim neque volutpat ac tincidunt vitae semper quis. Gravida hendrerit lectus a molestie lorem ipsum dolor sit amet. Mauris sit amet massa vitae tortor condimentum lacinia. Blandit volutpat maecenas volutpat blandit aliquam. Massa eget egestas purus viverra accumsan in nisl nisi scelerisque. Imperdiet dui accumsan sit amet nulla facilisi morbi tempus iaculis.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "26fe8d0985c", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + }, + ], +} +`; + +exports[`patches rich text image nodes > newLinkTo > shared slice 1`] = ` +{ + "slices": [ + { + "id": "9b9dd0f2c3f", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Diam", + "type": "heading4", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "f7b961adcf1", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Egestas maecenas pharetra convallis posuere morbi leo urna molestie at. Cras fermentum odio eu feugiat pretium. Neque aliquam vestibulum morbi blandit cursus risus at ultrices mi tempus imperdiet nulla malesuada. Fermentum posuere urna nec tincidunt praesent semper feugiat nibh. Eleifend donec pretium vulputate sapien.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "f7b961adcf1", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + "group": [ + { + "field": [ + { + "spans": [], + "text": "Quis Enim Lobortis", + "type": "heading2", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "f7b961adcf1", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1604537466608-109fa2f16c3b?w=4240&h=2832&fit=crop", + }, + ], + }, + ], + }, + "slice_label": null, + "slice_type": "nunc", + "variation": "ullamcorper", + "version": "8bfc905", + }, + ], +} +`; + +exports[`patches rich text image nodes > newLinkTo > slice 1`] = ` +{ + "slices": [ + { + "id": "5306297c5ed", + "items": [ + { + "field": [ + { + "spans": [], + "text": "Ultrices dui sapien eget mi proin sed libero enim sed. Diam ut venenatis tellus in metus vulputate eu scelerisque felis imperdiet. Sed nisi lacus sed viverra tellus in hac habitasse. Eget mi proin sed libero enim sed faucibus turpis. Pellentesque id nibh tortor id aliquet. Aliquam sem et tortor consequat id porta nibh venenatis cras sed felis.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "54f4a69ff72", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=2560&h=1440&fit=crop", + }, + ], + }, + ], + "primary": { + "field": [ + { + "spans": [], + "text": "Pharetra et ultrices neque ornare aenean euismod. Mauris sit amet massa vitae tortor condimentum lacinia quis vel eros donec ac.", + "type": "preformatted", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "54f4a69ff72", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1497436072909-60f360e1d4b1?w=2560&h=1440&fit=crop", + }, + ], + }, + "slice_label": "Vel", + "slice_type": "hac_habitasse", + }, + ], +} +`; + +exports[`patches rich text image nodes > newLinkTo > static zone 1`] = ` +{ + "field": [ + { + "spans": [], + "text": "Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis. Tincidunt praesent semper feugiat nibh sed pulvinar proin gravida hendrerit lectus a.", + "type": "paragraph", + }, + { + "alt": null, + "copyright": null, + "dimensions": { + "height": 1, + "width": 1, + }, + "edit": { + "background": "transparent", + "x": 0, + "y": 0, + "zoom": 1, + }, + "id": "ab1d17b04a9", + "linkTo": { + "id": "other.id-existing", + "link_type": "Document", + }, + "type": "image", + "url": "https://images.unsplash.com/photo-1553531384-397c80973a0b?w=4335&h=6502&fit=crop", + }, + ], +} +`; diff --git a/test/__testutils__/createClient.ts b/test/__testutils__/createClient.ts index e77d14f5..c436068a 100644 --- a/test/__testutils__/createClient.ts +++ b/test/__testutils__/createClient.ts @@ -1,3 +1,5 @@ +import type { TaskContext } from "vitest" + import fetch from "node-fetch" import { createRepositoryName } from "./createRepositoryName" @@ -7,22 +9,23 @@ import * as prismic from "../../src" type CreateTestClientArgs = ( | { repositoryName?: string - apiEndpoint?: never + documentAPIEndpoint?: never } | { repositoryName?: never - apiEndpoint?: string + documentAPIEndpoint?: string } ) & { + ctx: TaskContext clientConfig?: prismic.ClientConfig } export const createTestClient = ( - args: CreateTestClientArgs = {}, + args: CreateTestClientArgs, ): prismic.Client => { - const repositoryName = args.repositoryName || createRepositoryName() + const repositoryName = args.repositoryName || createRepositoryName(args.ctx) - return prismic.createClient(args.apiEndpoint || repositoryName, { + return prismic.createClient(args.documentAPIEndpoint || repositoryName, { fetch, ...args.clientConfig, }) diff --git a/test/__testutils__/createRepositoryName.ts b/test/__testutils__/createRepositoryName.ts index b049e632..2a85c262 100644 --- a/test/__testutils__/createRepositoryName.ts +++ b/test/__testutils__/createRepositoryName.ts @@ -1,9 +1,9 @@ -import { expect } from "vitest" +import type { TaskContext } from "vitest" import * as crypto from "node:crypto" -export const createRepositoryName = (): string => { - const seed = expect.getState().currentTestName +export const createRepositoryName = (ctx: TaskContext): string => { + const seed = ctx.task.name if (!seed) { throw new Error( `createRepositoryName() can only be called within a Vitest test.`, diff --git a/test/__testutils__/createWriteClient.ts b/test/__testutils__/createWriteClient.ts new file mode 100644 index 00000000..32ebde3b --- /dev/null +++ b/test/__testutils__/createWriteClient.ts @@ -0,0 +1,29 @@ +import type { TaskContext } from "vitest" + +import fetch from "node-fetch" + +import { createRepositoryName } from "./createRepositoryName" + +import * as prismic from "../../src" + +type CreateTestWriteClientArgs = { + repositoryName?: string +} & { + ctx: TaskContext + clientConfig?: Partial +} + +export const createTestWriteClient = ( + args: CreateTestWriteClientArgs, +): prismic.WriteClient => { + const repositoryName = args.repositoryName || createRepositoryName(args.ctx) + + return prismic.createWriteClient(repositoryName, { + fetch, + writeToken: "xxx", + // We create unique endpoints so we can run tests concurrently + assetAPIEndpoint: `https://${repositoryName}.asset-api.prismic.io`, + migrationAPIEndpoint: `https://${repositoryName}.migration.prismic.io`, + ...args.clientConfig, + }) +} diff --git a/test/__testutils__/mockPrismicAssetAPI.ts b/test/__testutils__/mockPrismicAssetAPI.ts new file mode 100644 index 00000000..8796093d --- /dev/null +++ b/test/__testutils__/mockPrismicAssetAPI.ts @@ -0,0 +1,256 @@ +import { type TestContext } from "vitest" + +import type { RestRequest } from "msw" +import { rest } from "msw" + +import type { WriteClient } from "../../src" +import type { + Asset, + GetAssetsResult, + PatchAssetParams, + PostAssetResult, +} from "../../src/types/api/asset/asset" +import type { + AssetTag, + GetAssetTagsResult, + PostAssetTagParams, + PostAssetTagResult, +} from "../../src/types/api/asset/tag" + +type MockPrismicAssetAPIArgs = { + ctx: TestContext + client: WriteClient + writeToken?: string + requiredHeaders?: Record + existingAssets?: Asset[][] | number[] + newAssets?: Asset[] + existingTags?: AssetTag[] + newTags?: AssetTag[] +} + +type MockPrismicAssetAPIReturnType = { + assetsDatabase: Asset[][] + tagsDatabase: AssetTag[] +} + +const DEFAULT_ASSET: Asset = { + id: "default", + uploader_id: "", + created_at: 0, + last_modified: 0, + kind: "image", + filename: "default.jpg", + extension: "jpg", + url: "https://example.com/default.jpg", + width: 1, + height: 1, + size: 1, + notes: "", + credits: "", + alt: "", + origin_url: "", + search_highlight: { filename: [], notes: [], credits: [], alt: [] }, + tags: [], +} + +export const mockAsset = (ctx: TestContext, assets?: Partial): Asset => { + const { id, url } = ctx.mock.value.image({ + state: "filled", + }) + + return { + ...DEFAULT_ASSET, + id, + url, + ...assets, + } +} + +export const mockPrismicAssetAPI = ( + args: MockPrismicAssetAPIArgs, +): MockPrismicAssetAPIReturnType => { + const repositoryName = args.client.repositoryName + const assetAPIEndpoint = args.client.assetAPIEndpoint + const writeToken = args.writeToken || args.client.writeToken + + const assetsDatabase: Asset[][] = + args.existingAssets?.map((assets) => { + if (typeof assets === "number") { + return Array(assets) + .fill(1) + .map(() => mockAsset(args.ctx)) + } + + return assets + }) || [] + const tagsDatabase: AssetTag[] = args.existingTags || [] + + const validateHeaders = (req: RestRequest) => { + if (args.requiredHeaders) { + for (const name in args.requiredHeaders) { + const requiredValue = args.requiredHeaders[name] + + args.ctx.expect(req.headers.get(name)).toBe(requiredValue) + } + } + } + + args.ctx.server.use( + rest.get( + new URL("assets", assetAPIEndpoint).toString(), + async (req, res, ctx) => { + if ( + req.headers.get("authorization") !== `Bearer ${writeToken}` || + req.headers.get("repository") !== repositoryName + ) { + return res(ctx.status(401), ctx.json({ error: "unauthorized" })) + } + + validateHeaders(req) + + const index = Number.parseInt(req.url.searchParams.get("cursor") || "0") + const items: Asset[] = assetsDatabase[index] || [] + + let missing_ids: string[] | undefined + const ids = req.url.searchParams.getAll("ids") + if (ids.length) { + missing_ids = ids.filter((id) => + assetsDatabase.flat().find((item) => item.id === id), + ) + } + + const response: GetAssetsResult = { + total: items.length, + items, + is_opensearch_result: false, + cursor: assetsDatabase[index + 1] ? `${index + 1}` : undefined, + missing_ids, + } + + return res(ctx.json(response)) + }, + ), + rest.post( + new URL("assets", assetAPIEndpoint).toString(), + async (req, res, ctx) => { + if ( + req.headers.get("authorization") !== `Bearer ${writeToken}` || + req.headers.get("repository") !== repositoryName + ) { + return res(ctx.status(401), ctx.json({ error: "unauthorized" })) + } + + validateHeaders(req) + + const response: PostAssetResult = + args.newAssets?.shift() || mockAsset(args.ctx) + + // Save the asset in DB + assetsDatabase.push([response]) + + return res(ctx.json(response)) + }, + ), + rest.patch( + new URL("assets/:id", assetAPIEndpoint).toString(), + async (req, res, ctx) => { + if ( + req.headers.get("authorization") !== `Bearer ${writeToken}` || + req.headers.get("repository") !== repositoryName + ) { + return res(ctx.status(401), ctx.json({ error: "unauthorized" })) + } + + validateHeaders(req) + + const { tags, ...body } = await req.json() + const asset = assetsDatabase + .flat() + .find((asset) => asset.id === req.params.id) + + if (!asset) { + return res(ctx.status(404), ctx.json({ error: "not found" })) + } + + const response: PostAssetResult = { + ...asset, + ...body, + tags: tags?.length + ? tagsDatabase.filter((tag) => tags.includes(tag.id)) + : asset.tags, + } + + // __pdf__ is used as a magic word to transform created assets into + // documents since we can't read FormData when creating assets... + if (response.tags?.some((tag) => tag.name === "__pdf__")) { + response.kind = "document" + response.width = undefined + response.height = undefined + } + + // Update asset in DB + for (const cursor in assetsDatabase) { + for (const asset in assetsDatabase[cursor]) { + if (assetsDatabase[cursor][asset].id === req.params.id) { + assetsDatabase[cursor][asset] = response + } + } + } + + return res(ctx.json(response)) + }, + ), + rest.get( + new URL("tags", assetAPIEndpoint).toString(), + async (req, res, ctx) => { + if ( + req.headers.get("authorization") !== `Bearer ${writeToken}` || + req.headers.get("repository") !== repositoryName + ) { + return res(ctx.status(401), ctx.json({ error: "unauthorized" })) + } + + validateHeaders(req) + + const items: AssetTag[] = tagsDatabase + const response: GetAssetTagsResult = { items } + + return res(ctx.json(response)) + }, + ), + rest.post( + new URL("tags", assetAPIEndpoint).toString(), + async (req, res, ctx) => { + if ( + req.headers.get("authorization") !== `Bearer ${writeToken}` || + req.headers.get("repository") !== repositoryName + ) { + return res(ctx.status(401), ctx.json({ error: "unauthorized" })) + } + + validateHeaders(req) + + const body = await req.json() + const tag: AssetTag = { + id: `${`${Date.now()}`.slice(-8)}-4444-4444-4444-121212121212`, + created_at: Date.now(), + last_modified: Date.now(), + name: body.name, + ...args.newTags?.find((tag) => tag.name === body.name), + } + + // Save the tag in DB + tagsDatabase.push(tag) + + const response: PostAssetTagResult = tag + + return res(ctx.status(201), ctx.json(response)) + }, + ), + ) + + return { + assetsDatabase, + tagsDatabase, + } +} diff --git a/test/__testutils__/mockPrismicMigrationAPI.ts b/test/__testutils__/mockPrismicMigrationAPI.ts new file mode 100644 index 00000000..25e1d275 --- /dev/null +++ b/test/__testutils__/mockPrismicMigrationAPI.ts @@ -0,0 +1,168 @@ +import type { TestContext } from "vitest" + +import type { RestRequest } from "msw" +import { rest } from "msw" + +import type { PrismicDocument, WriteClient } from "../../src" +import type { + PostDocumentParams, + PostDocumentResult, + PutDocumentParams, + PutDocumentResult, +} from "../../src/types/api/migration/document" + +type MockPrismicMigrationAPIArgs = { + ctx: TestContext + client: WriteClient + writeToken?: string + migrationAPIKey?: string + requiredHeaders?: Record + existingDocuments?: (PostDocumentResult | PrismicDocument)[] | number + newDocuments?: { id: string; masterLanguageDocumentID?: string }[] +} + +type MockPrismicMigrationAPIReturnType = { + documentsDatabase: Record< + string, + PostDocumentResult & Pick + > +} + +export const mockPrismicMigrationAPI = ( + args: MockPrismicMigrationAPIArgs, +): MockPrismicMigrationAPIReturnType => { + const repositoryName = args.client.repositoryName + const migrationAPIEndpoint = args.client.migrationAPIEndpoint + const writeToken = args.writeToken || args.client.writeToken + const migrationAPIKey = args.migrationAPIKey || args.client.migrationAPIKey + + const documentsDatabase: Record< + string, + PostDocumentResult & Pick + > = {} + + const validateHeaders = (req: RestRequest) => { + if (args.requiredHeaders) { + for (const name in args.requiredHeaders) { + const requiredValue = args.requiredHeaders[name] + + args.ctx.expect(req.headers.get(name)).toBe(requiredValue) + } + } + } + + if (args.existingDocuments) { + if (typeof args.existingDocuments === "number") { + for (let i = 0; i < args.existingDocuments; i++) { + const document = args.ctx.mock.value.document() + documentsDatabase[document.id] = { + title: args.ctx.mock.value.keyText({ state: "filled" }), + id: document.id, + lang: document.lang, + type: document.type, + uid: document.uid, + data: document.data, + } + } + } else { + for (const document of args.existingDocuments) { + if ("title" in document) { + documentsDatabase[document.id] = { ...document, data: {} } + } else { + documentsDatabase[document.id] = { + title: args.ctx.mock.value.keyText({ state: "filled" }), + id: document.id, + lang: document.lang, + type: document.type, + uid: document.uid, + data: document.data, + } + } + } + } + } + + args.ctx.server.use( + rest.post( + new URL("documents", migrationAPIEndpoint).toString(), + async (req, res, ctx) => { + if ( + req.headers.get("authorization") !== `Bearer ${writeToken}` || + req.headers.get("x-api-key") !== migrationAPIKey || + req.headers.get("repository") !== repositoryName + ) { + return res(ctx.status(403), ctx.json({ Message: "forbidden" })) + } + + validateHeaders(req) + + const body = await req.json() + + // New document is used when we need to have predictable document ID + // It's also used when we need to test for correct `alternate_language_id` + // because the API doesn't return it in the response. + const newDocument = args.newDocuments?.shift() + const id = newDocument?.id || args.ctx.mock.value.document().id + + if (newDocument?.masterLanguageDocumentID) { + args.ctx + .expect(body.alternate_language_id) + .toBe(newDocument.masterLanguageDocumentID) + } + + const response: PostDocumentResult = { + title: body.title, + id, + lang: body.lang, + type: body.type, + uid: body.uid, + } + + // Save the document in DB + documentsDatabase[id] = { ...response, data: body.data } + + return res(ctx.status(201), ctx.json(response)) + }, + ), + rest.put( + new URL("documents/:id", migrationAPIEndpoint).toString(), + async (req, res, ctx) => { + if ( + req.headers.get("authorization") !== `Bearer ${writeToken}` || + req.headers.get("x-api-key") !== migrationAPIKey || + req.headers.get("repository") !== repositoryName + ) { + return res(ctx.status(403), ctx.json({ Message: "forbidden" })) + } + + validateHeaders(req) + + const document = documentsDatabase[req.params.id as string] + + if (!document) { + return res(ctx.status(404)) + } + + const body = await req.json() + + const response: PutDocumentResult = { + title: body.title || document.title, + id: req.params.id as string, + lang: document.lang, + type: document.type, + uid: body.uid, + } + + // Update the document in DB + documentsDatabase[req.params.id as string] = { + ...response, + data: body.data, + } + + return res(ctx.json(response)) + }, + ), + ) + + return { documentsDatabase } +} diff --git a/test/__testutils__/mockPrismicRestAPIV2.ts b/test/__testutils__/mockPrismicRestAPIV2.ts index d4db1d5e..d8b500f3 100644 --- a/test/__testutils__/mockPrismicRestAPIV2.ts +++ b/test/__testutils__/mockPrismicRestAPIV2.ts @@ -1,4 +1,4 @@ -import type { TestContext } from "vitest" +import type { TaskContext, TestContext } from "vitest" import { expect } from "vitest" import { rest } from "msw" @@ -10,7 +10,7 @@ import type * as prismic from "../../src" const DEFAULT_DELAY = 0 type MockPrismicRestAPIV2Args = { - ctx: TestContext + ctx: TestContext & TaskContext accessToken?: string repositoryResponse?: prismic.Repository queryResponse?: prismic.Query | prismic.Query[] @@ -19,7 +19,7 @@ type MockPrismicRestAPIV2Args = { } export const mockPrismicRestAPIV2 = (args: MockPrismicRestAPIV2Args): void => { - const repositoryName = createRepositoryName() + const repositoryName = createRepositoryName(args.ctx) const repositoryEndpoint = `https://${repositoryName}.cdn.prismic.io/api/v2` const queryEndpoint = new URL( "documents/search", diff --git a/test/__testutils__/testAbortableMethod.ts b/test/__testutils__/testAbortableMethod.ts index 164a194f..d33fd973 100644 --- a/test/__testutils__/testAbortableMethod.ts +++ b/test/__testutils__/testAbortableMethod.ts @@ -22,7 +22,7 @@ export const testAbortableMethod = ( mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(async () => { await args.run(client, { @@ -43,7 +43,7 @@ export const testAbortableMethod = ( mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(async () => { await args.run(client, { diff --git a/test/__testutils__/testAnyGetMethod.ts b/test/__testutils__/testAnyGetMethod.ts index c50d93b4..99ea8d41 100644 --- a/test/__testutils__/testAnyGetMethod.ts +++ b/test/__testutils__/testAnyGetMethod.ts @@ -43,6 +43,7 @@ export const testAnyGetMethodFactory = ( const client = createTestClient({ clientConfig: args.clientConfig, + ctx, }) const res = await args.run(client) diff --git a/test/__testutils__/testConcurrentMethod.ts b/test/__testutils__/testConcurrentMethod.ts index cd736b4e..1d3f4a9c 100644 --- a/test/__testutils__/testConcurrentMethod.ts +++ b/test/__testutils__/testConcurrentMethod.ts @@ -52,9 +52,9 @@ export const testConcurrentMethod = ( queryDelay: 10, }) - const client = createTestClient({ clientConfig: { fetch: fetchSpy } }) + const client = createTestClient({ clientConfig: { fetch: fetchSpy }, ctx }) - const graphqlURL = `https://${createRepositoryName()}.cdn.prismic.io/graphql` + const graphqlURL = `https://${createRepositoryName(ctx)}.cdn.prismic.io/graphql` const graphqlResponse = { foo: "bar" } ctx.server.use( rest.get(graphqlURL, (req, res, ctx) => { diff --git a/test/__testutils__/testFetchOptions.ts b/test/__testutils__/testFetchOptions.ts index 95161a52..f8d08399 100644 --- a/test/__testutils__/testFetchOptions.ts +++ b/test/__testutils__/testFetchOptions.ts @@ -50,6 +50,7 @@ export const testFetchOptions = ( fetch: fetchSpy, fetchOptions, }, + ctx, }) await args.run(client) @@ -90,6 +91,7 @@ export const testFetchOptions = ( clientConfig: { fetch: fetchSpy, }, + ctx, }) await args.run(client, { fetchOptions }) diff --git a/test/__testutils__/testGetTTL.ts b/test/__testutils__/testGetTTL.ts index 1a3d9605..e2337d58 100644 --- a/test/__testutils__/testGetTTL.ts +++ b/test/__testutils__/testGetTTL.ts @@ -52,7 +52,7 @@ export const testGetWithinTTL = ( ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) if (args.beforeFirstGet) { args.beforeFirstGet({ client }) @@ -112,7 +112,7 @@ export const testGetOutsideTTL = ( ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) if (args.beforeFirstGet) { args.beforeFirstGet({ client }) diff --git a/test/__testutils__/testMigrationFieldPatching.ts b/test/__testutils__/testMigrationFieldPatching.ts new file mode 100644 index 00000000..b74d8caf --- /dev/null +++ b/test/__testutils__/testMigrationFieldPatching.ts @@ -0,0 +1,219 @@ +import type { TestContext } from "vitest" +import { it as _it, describe, vi } from "vitest" + +import { rest } from "msw" + +import { createPagedQueryResponses } from "./createPagedQueryResponses" +import { createTestWriteClient } from "./createWriteClient" +import { mockAsset, mockPrismicAssetAPI } from "./mockPrismicAssetAPI" +import { mockPrismicMigrationAPI } from "./mockPrismicMigrationAPI" +import { mockPrismicRestAPIV2 } from "./mockPrismicRestAPIV2" + +import * as prismic from "../../src" +import type { Asset } from "../../src/types/api/asset/asset" + +// Skip test on Node 16 and 18 (File and FormData support) +const isNode16 = process.version.startsWith("v16") +const isNode18 = process.version.startsWith("v18") +const it = _it.skipIf(isNode16 || isNode18) + +type GetDataArgs = { + ctx: TestContext + migration: prismic.Migration + existingAssets: Asset[] + existingDocuments: prismic.PrismicDocument[] + otherCreateDocument: prismic.PrismicMigrationDocument + otherFromPrismicDocument: prismic.PrismicMigrationDocument + mockedDomain: string +} + +type InternalTestMigrationFieldPatchingArgs = { + getData: (args: GetDataArgs) => prismic.ExistingPrismicDocument["data"] + expectStrictEqual?: boolean + mode?: "new" | "fromPrismic" +} + +const internalTestMigrationFieldPatching = ( + description: string, + args: InternalTestMigrationFieldPatchingArgs, +): void => { + it.concurrent(description, async (ctx) => { + vi.useFakeTimers() + + const client = createTestWriteClient({ ctx }) + + // Mock Document API + const repository = ctx.mock.api.repository() + repository.languages[0].id = "en-us" + repository.languages[0].is_master = true + const queryResponse = createPagedQueryResponses({ + ctx, + pages: 1, + pageSize: 1, + }) + queryResponse[0].results[0].id = "other.id-existing" + mockPrismicRestAPIV2({ ctx, repositoryResponse: repository, queryResponse }) + + // Mock Asset API + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + existingAssets: [[mockAsset(ctx, { id: "asset.id-existing" })]], + }) + + const mockedDomain = `https://${client.repositoryName}.example.com` + ctx.server.use( + rest.get(`${mockedDomain}/:path`, (__testutils__req, res, ctx) => + res(ctx.text("foo")), + ), + ) + + // Setup migration + const migration = prismic.createMigration() + + // Create document + const { id: _createOriginalID, ...createDocument } = { + ...ctx.mock.value.document(), + uid: ctx.mock.value.keyText(), + } + + const migrationOtherDocument = migration.createDocument( + createDocument, + "other.create", + ) + + // Create document from Prismic + const fromPrismicDocument = { + ...ctx.mock.value.document(), + uid: ctx.mock.value.keyText(), + } + + const migrationOtherRepositoryDocument = + migration.createDocumentFromPrismic( + fromPrismicDocument, + "other.fromPrismic", + ) + + // Create new document + const { id: originalID, ...newDocument } = ctx.mock.value.document() + const newID = + !args.mode || args.mode === "new" ? "id-new" : "id-fromPrismic" + + // Mock Migration API + const { documentsDatabase } = mockPrismicMigrationAPI({ + ctx, + client, + newDocuments: [ + { id: "other.id-create" }, + { id: "other.id-fromPrismic" }, + { id: newID }, + ], + }) + + // Get new document data + newDocument.data = args.getData({ + ctx, + migration, + existingAssets: assetsDatabase.flat(), + existingDocuments: queryResponse[0].results, + otherCreateDocument: migrationOtherDocument, + otherFromPrismicDocument: migrationOtherRepositoryDocument, + mockedDomain, + }) + + if (!args.mode || args.mode === "new") { + migration.createDocument(newDocument, "new") + } else { + migration.createDocumentFromPrismic( + { ...newDocument, id: originalID }, + args.mode, + ) + } + + // We speed up the internal rate limiter to make these tests run faster (from 4500ms to nearly instant) + const migrationProcess = client.migrate(migration) + await vi.runAllTimersAsync() + await migrationProcess + + const { data } = documentsDatabase[newID] + + if (args.expectStrictEqual) { + ctx.expect(data).toStrictEqual(newDocument.data) + } else { + ctx.expect(data).toMatchSnapshot() + } + + vi.restoreAllMocks() + }) +} + +type TestMigrationFieldPatchingFactoryCases< + TField extends prismic.ExistingPrismicDocument["data"][string], +> = Record TField> + +export const testMigrationFieldPatching = < + TField extends prismic.ExistingPrismicDocument["data"][string], +>( + description: string, + cases: TestMigrationFieldPatchingFactoryCases, + args: Omit = {}, +): void => { + describe(description, () => { + for (const name in cases) { + const getFields = (args: GetDataArgs) => { + return { field: cases[name](args) } + } + + describe(name, () => { + internalTestMigrationFieldPatching("static zone", { + getData(args) { + return getFields(args) + }, + ...args, + }) + + internalTestMigrationFieldPatching("group", { + getData(args) { + return { + group: [getFields(args)], + } + }, + ...args, + }) + + internalTestMigrationFieldPatching("slice", { + getData(args) { + return { + slices: [ + { + ...args.ctx.mock.value.slice(), + primary: getFields(args), + items: [getFields(args)], + }, + ], + } + }, + ...args, + }) + + internalTestMigrationFieldPatching("shared slice", { + getData(args) { + return { + slices: [ + { + ...args.ctx.mock.value.sharedSlice(), + primary: { + ...getFields(args), + group: [getFields(args)], + }, + items: [getFields(args)], + }, + ], + } + }, + ...args, + }) + }) + } + }) +} diff --git a/test/client-buildQueryURL.test.ts b/test/client-buildQueryURL.test.ts index 92a1b730..36d0a3b9 100644 --- a/test/client-buildQueryURL.test.ts +++ b/test/client-buildQueryURL.test.ts @@ -19,7 +19,7 @@ it("builds a query URL using the master ref", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.buildQueryURL() const url = new URL(res) @@ -42,7 +42,7 @@ it("includes the `x-d` param in development", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.buildQueryURL() const url = new URL(res) @@ -60,7 +60,7 @@ it("includes params if provided", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.buildQueryURL(params) const url = new URL(res) @@ -90,7 +90,7 @@ it("includes default params if provided", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient({ clientConfig }) + const client = createTestClient({ clientConfig, ctx }) const res = await client.buildQueryURL() const url = new URL(res) @@ -123,7 +123,7 @@ it("merges params and default params if provided", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient({ clientConfig }) + const client = createTestClient({ clientConfig, ctx }) const res = await client.buildQueryURL(params) const url = new URL(res) diff --git a/test/client-dangerouslyGetAll.test.ts b/test/client-dangerouslyGetAll.test.ts index e48f72e6..b767f419 100644 --- a/test/client-dangerouslyGetAll.test.ts +++ b/test/client-dangerouslyGetAll.test.ts @@ -138,7 +138,7 @@ it("throttles requests past first page", async (ctx) => { queryDelay, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const startTime = Date.now() await client.dangerouslyGetAll() @@ -173,7 +173,7 @@ it("does not throttle single page queries", async (ctx) => { queryDelay, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const startTime = Date.now() await client.dangerouslyGetAll() diff --git a/test/client-get.test.ts b/test/client-get.test.ts index e4caa71b..61893155 100644 --- a/test/client-get.test.ts +++ b/test/client-get.test.ts @@ -62,7 +62,7 @@ testGetMethod("merges params and default params if provided", { it("uses cached repository metadata within the client's repository cache TTL", async (ctx) => { const fetchSpy = vi.fn(fetch) - const client = createTestClient({ clientConfig: { fetch: fetchSpy } }) + const client = createTestClient({ clientConfig: { fetch: fetchSpy }, ctx }) const repositoryResponse1 = ctx.mock.api.repository() repositoryResponse1.refs = [ctx.mock.api.ref({ isMasterRef: true })] diff --git a/test/client-getFirst.test.ts b/test/client-getFirst.test.ts index 307e6814..48634f67 100644 --- a/test/client-getFirst.test.ts +++ b/test/client-getFirst.test.ts @@ -84,7 +84,7 @@ it("throws if no documents were returned", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(() => client.getFirst()).rejects.toThrowError( /no documents were returned/i, diff --git a/test/client-getMasterRef.test.ts b/test/client-getMasterRef.test.ts index 0e1b98a9..73f84075 100644 --- a/test/client-getMasterRef.test.ts +++ b/test/client-getMasterRef.test.ts @@ -17,7 +17,7 @@ it("returns the master ref", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getMasterRef() expect(res).toStrictEqual(masterRef) diff --git a/test/client-getRefByID.test.ts b/test/client-getRefByID.test.ts index f4449ce7..92f018cb 100644 --- a/test/client-getRefByID.test.ts +++ b/test/client-getRefByID.test.ts @@ -18,7 +18,7 @@ it("returns a ref by ID", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getRefByID(ref2.id) expect(res).toStrictEqual(ref2) @@ -29,7 +29,7 @@ it("throws if ref could not be found", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(() => client.getRefByID("non-existant")).rejects.toThrowError( /could not be found/i, diff --git a/test/client-getRefByLabel.test.ts b/test/client-getRefByLabel.test.ts index d5798736..5d219256 100644 --- a/test/client-getRefByLabel.test.ts +++ b/test/client-getRefByLabel.test.ts @@ -18,7 +18,7 @@ it("returns a ref by label", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getRefByLabel(ref2.label) expect(res).toStrictEqual(ref2) @@ -29,7 +29,7 @@ it("throws if ref could not be found", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(() => client.getRefByLabel("non-existant")).rejects.toThrowError( /could not be found/i, diff --git a/test/client-getRefs.test.ts b/test/client-getRefs.test.ts index 32c03bd4..55ff40a6 100644 --- a/test/client-getRefs.test.ts +++ b/test/client-getRefs.test.ts @@ -13,7 +13,7 @@ it("returns all refs", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getRefs() expect(res).toStrictEqual(repositoryResponse.refs) diff --git a/test/client-getReleaseById.test.ts b/test/client-getReleaseById.test.ts index 35c4be77..2521e80b 100644 --- a/test/client-getReleaseById.test.ts +++ b/test/client-getReleaseById.test.ts @@ -18,7 +18,7 @@ it("returns a Release by ID", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getReleaseByID(ref2.id) expect(res).toStrictEqual(ref2) @@ -27,7 +27,7 @@ it("returns a Release by ID", async (ctx) => { it("throws if Release could not be found", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(() => client.getReleaseByID("non-existant"), diff --git a/test/client-getReleaseByLabel.test.ts b/test/client-getReleaseByLabel.test.ts index 81c54ccc..fbb861ab 100644 --- a/test/client-getReleaseByLabel.test.ts +++ b/test/client-getReleaseByLabel.test.ts @@ -18,7 +18,7 @@ it("returns a Release by label", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getReleaseByLabel(ref2.label) expect(res).toStrictEqual(ref2) @@ -27,7 +27,7 @@ it("returns a Release by label", async (ctx) => { it("throws if Release could not be found", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(() => client.getReleaseByLabel("non-existant"), diff --git a/test/client-getReleases.test.ts b/test/client-getReleases.test.ts index 61cefcc4..1c249156 100644 --- a/test/client-getReleases.test.ts +++ b/test/client-getReleases.test.ts @@ -13,7 +13,7 @@ it("returns all Releases", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getReleases() expect(res).toStrictEqual( diff --git a/test/client-getRepository.test.ts b/test/client-getRepository.test.ts index 3fe18100..da19bd6b 100644 --- a/test/client-getRepository.test.ts +++ b/test/client-getRepository.test.ts @@ -14,7 +14,7 @@ it("returns repository metadata", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getRepository() expect(res).toStrictEqual(repositoryResponse) @@ -33,7 +33,7 @@ it("includes access token if configured", async (ctx) => { ctx, }) - const client = createTestClient({ clientConfig }) + const client = createTestClient({ clientConfig, ctx }) const res = await client.getRepository() expect(res).toStrictEqual(repositoryResponse) diff --git a/test/client-getTags.test.ts b/test/client-getTags.test.ts index c170591e..4723caea 100644 --- a/test/client-getTags.test.ts +++ b/test/client-getTags.test.ts @@ -15,7 +15,7 @@ it("returns all tags", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getTags() expect(res).toStrictEqual(repositoryResponse.tags) @@ -44,7 +44,7 @@ it("uses form endpoint if available", async (ctx) => { }), ) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.getTags() expect(res).toStrictEqual(tagsResponse) @@ -76,7 +76,7 @@ it("sends access token if form endpoint is used", async (ctx) => { }), ) - const client = createTestClient({ clientConfig: { accessToken } }) + const client = createTestClient({ clientConfig: { accessToken }, ctx }) const res = await client.getTags() expect(res).toStrictEqual(tagsResponse) diff --git a/test/client-graphQLFetch.test.ts b/test/client-graphQLFetch.test.ts index 5a4c540e..12a801ac 100644 --- a/test/client-graphQLFetch.test.ts +++ b/test/client-graphQLFetch.test.ts @@ -11,7 +11,7 @@ import { testConcurrentMethod } from "./__testutils__/testConcurrentMethod" it("resolves a query", async (ctx) => { const repositoryResponse = ctx.mock.api.repository() - const graphqlURL = `https://${createRepositoryName()}.cdn.prismic.io/graphql` + const graphqlURL = `https://${createRepositoryName(ctx)}.cdn.prismic.io/graphql` const graphqlResponse = { foo: "bar" } mockPrismicRestAPIV2({ @@ -26,7 +26,7 @@ it("resolves a query", async (ctx) => { }), ) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.graphQLFetch(graphqlURL) const json = await res.json() @@ -38,7 +38,7 @@ it("merges provided headers with defaults", async (ctx) => { repositoryResponse.integrationFieldsRef = ctx.mock.api.ref().ref const ref = "custom-ref" - const graphqlURL = `https://${createRepositoryName()}.cdn.prismic.io/graphql` + const graphqlURL = `https://${createRepositoryName(ctx)}.cdn.prismic.io/graphql` const graphqlResponse = { foo: "bar" } mockPrismicRestAPIV2({ @@ -57,7 +57,7 @@ it("merges provided headers with defaults", async (ctx) => { }), ) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.graphQLFetch(graphqlURL, { headers: { "Prismic-Ref": ref, @@ -73,7 +73,7 @@ it("includes Authorization header if access token is provided", async (ctx) => { const repositoryResponse = ctx.mock.api.repository() repositoryResponse.integrationFieldsRef = ctx.mock.api.ref().ref - const graphqlURL = `https://${createRepositoryName()}.cdn.prismic.io/graphql` + const graphqlURL = `https://${createRepositoryName(ctx)}.cdn.prismic.io/graphql` const graphqlResponse = { foo: "bar" } mockPrismicRestAPIV2({ @@ -92,7 +92,7 @@ it("includes Authorization header if access token is provided", async (ctx) => { }), ) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.graphQLFetch(graphqlURL) const json = await res.json() @@ -104,7 +104,7 @@ it("includes integration fields header if ref is available", async (ctx) => { const repositoryResponse = ctx.mock.api.repository() const accessToken = "accessToken" - const graphqlURL = `https://${createRepositoryName()}.cdn.prismic.io/graphql` + const graphqlURL = `https://${createRepositoryName(ctx)}.cdn.prismic.io/graphql` const graphqlResponse = { foo: "bar" } mockPrismicRestAPIV2({ @@ -123,7 +123,7 @@ it("includes integration fields header if ref is available", async (ctx) => { }), ) - const client = createTestClient({ clientConfig: { accessToken } }) + const client = createTestClient({ clientConfig: { accessToken }, ctx }) const res = await client.graphQLFetch(graphqlURL) const json = await res.json() @@ -133,7 +133,7 @@ it("includes integration fields header if ref is available", async (ctx) => { it("optimizes queries by removing whitespace", async (ctx) => { const repositoryResponse = ctx.mock.api.repository() - const graphqlURL = `https://${createRepositoryName()}.cdn.prismic.io/graphql` + const graphqlURL = `https://${createRepositoryName(ctx)}.cdn.prismic.io/graphql` const graphqlResponse = { foo: "bar" } const graphqlURLWithUncompressedQuery = new URL(graphqlURL) @@ -168,7 +168,7 @@ it("optimizes queries by removing whitespace", async (ctx) => { }), ) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.graphQLFetch( graphqlURLWithUncompressedQuery.toString(), ) @@ -181,7 +181,7 @@ it("includes a ref URL parameter to cache-bust", async (ctx) => { const repositoryResponse = ctx.mock.api.repository() const ref = getMasterRef(repositoryResponse) - const graphqlURL = `https://${createRepositoryName()}.cdn.prismic.io/graphql` + const graphqlURL = `https://${createRepositoryName(ctx)}.cdn.prismic.io/graphql` const graphqlResponse = { foo: "bar" } mockPrismicRestAPIV2({ @@ -199,7 +199,7 @@ it("includes a ref URL parameter to cache-bust", async (ctx) => { }), ) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.graphQLFetch(graphqlURL) const json = await res.json() @@ -214,7 +214,7 @@ it("is abortable with an AbortController", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(async () => { await client.graphQLFetch("https://foo.cdn.prismic.io/graphql", { @@ -226,7 +226,7 @@ it("is abortable with an AbortController", async (ctx) => { testConcurrentMethod("does not share concurrent equivalent network requests", { run: (client, params) => client.graphQLFetch( - `https://${createRepositoryName()}.cdn.prismic.io/graphql`, + `https://${client.repositoryName}.cdn.prismic.io/graphql`, params, ), mode: "NOT-SHARED___graphQL", diff --git a/test/client-queryContentFromRef.test.ts b/test/client-queryContentFromRef.test.ts index aa74ad65..c5d6e676 100644 --- a/test/client-queryContentFromRef.test.ts +++ b/test/client-queryContentFromRef.test.ts @@ -42,7 +42,7 @@ it("uses master ref if manual thunk ref returns non-string value", async (ctx) = ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.queryContentFromRef(async () => undefined) diff --git a/test/client-queryContentFromReleaseById.test.ts b/test/client-queryContentFromReleaseById.test.ts index 3855d2ef..2b519e12 100644 --- a/test/client-queryContentFromReleaseById.test.ts +++ b/test/client-queryContentFromReleaseById.test.ts @@ -28,7 +28,7 @@ it("uses a releases ref by ID", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.queryContentFromReleaseByID(ref1.id) diff --git a/test/client-queryContentFromReleaseByLabel.test.ts b/test/client-queryContentFromReleaseByLabel.test.ts index 8eb2e78f..e43d3a49 100644 --- a/test/client-queryContentFromReleaseByLabel.test.ts +++ b/test/client-queryContentFromReleaseByLabel.test.ts @@ -28,7 +28,7 @@ it("uses a releases ref by label", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.queryContentFromReleaseByLabel(ref1.label) diff --git a/test/client-resolvePreviewUrl.test.ts b/test/client-resolvePreviewUrl.test.ts index 46b13418..50950f71 100644 --- a/test/client-resolvePreviewUrl.test.ts +++ b/test/client-resolvePreviewUrl.test.ts @@ -32,7 +32,7 @@ it("resolves a preview url in the browser", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, defaultURL: "defaultURL", @@ -67,7 +67,7 @@ it("resolves a preview url using a server req object", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, @@ -102,7 +102,7 @@ it("resolves a preview url using a Web API-based server req object", async (ctx) ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, @@ -135,7 +135,7 @@ it("resolves a preview url using a Web API-based server req object containing a ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, @@ -168,7 +168,7 @@ it("allows providing an explicit documentId and previewToken", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, @@ -180,14 +180,14 @@ it("allows providing an explicit documentId and previewToken", async (ctx) => { expect(res).toBe(`/${document.uid}`) }) -it("returns defaultURL if current url does not contain preview params in browser", async () => { +it("returns defaultURL if current url does not contain preview params in browser", async (ctx) => { const defaultURL = "defaultURL" // Set a global Location object without the parameters we need for automatic // preview support. globalThis.location = { ...globalThis.location, search: "" } - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, defaultURL, @@ -199,11 +199,11 @@ it("returns defaultURL if current url does not contain preview params in browser globalThis.location = undefined }) -it("returns defaultURL if req does not contain preview params in server req object", async () => { +it("returns defaultURL if req does not contain preview params in server req object", async (ctx) => { const defaultURL = "defaultURL" const req = {} - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, @@ -213,10 +213,10 @@ it("returns defaultURL if req does not contain preview params in server req obje expect(res).toBe(defaultURL) }) -it("returns defaultURL if no preview context is available", async () => { +it("returns defaultURL if no preview context is available", async (ctx) => { const defaultURL = "defaultURL" - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.resolvePreviewURL({ linkResolver: (document) => `/${document.uid}`, defaultURL, @@ -245,7 +245,7 @@ it("returns defaultURL if resolved URL is not a string", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.resolvePreviewURL({ linkResolver: () => null, defaultURL, diff --git a/test/client.test.ts b/test/client.test.ts index 1f3a7d77..ac9bf7e9 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -10,7 +10,7 @@ import { mockPrismicRestAPIV2 } from "./__testutils__/mockPrismicRestAPIV2" import * as prismic from "../src" -it("createClient creates a Client", () => { +it("creates a Client with `createClient`", () => { const client = prismic.createClient("qwerty", { fetch: vi.fn(), }) @@ -36,7 +36,7 @@ it("creates a client with a Rest API V2 endpoint", () => { expect(client.endpoint).toBe(endpoint) }) -it("client has correct default state", () => { +it("has correct default state", () => { const endpoint = prismic.getRepositoryEndpoint("qwerty") const options: prismic.ClientConfig = { accessToken: "accessToken", @@ -54,7 +54,7 @@ it("client has correct default state", () => { expect(client.defaultParams).toBe(options.defaultParams) }) -it("constructor throws if an invalid repository name is provided", () => { +it("throws in constructor if an invalid repository name is provided", () => { expect(() => { prismic.createClient("invalid repository name", { fetch: vi.fn(), @@ -67,7 +67,7 @@ it("constructor throws if an invalid repository name is provided", () => { }).toThrowError(prismic.PrismicError) }) -it("constructor throws if an invalid repository endpoint is provided", () => { +it("throws in constructor if an invalid repository endpoint is provided", () => { expect(() => { prismic.createClient("https://invalid url.cdn.prismic.io/api/v2", { fetch: vi.fn(), @@ -80,7 +80,7 @@ it("constructor throws if an invalid repository endpoint is provided", () => { }).toThrowError(prismic.PrismicError) }) -it("constructor throws if a prismic.io endpoint is given that is not for Rest API V2", () => { +it("throws in constructor if a prismic.io endpoint is given that is not for Rest API V2", () => { const fetch = vi.fn() const originalNodeEnv = process.env.NODE_ENV @@ -93,6 +93,10 @@ it("constructor throws if a prismic.io endpoint is given that is not for Rest AP prismic.createClient("https://qwerty.cdn.prismic.io/api/v1", { fetch }) }).toThrowError(prismic.PrismicError) + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0) + expect(() => { prismic.createClient("https://example.com/custom/endpoint", { fetch }) }, "Non-prismic.io endpoints are not checked").not.toThrow() @@ -105,10 +109,12 @@ it("constructor throws if a prismic.io endpoint is given that is not for Rest AP prismic.createClient(prismic.getRepositoryEndpoint("qwerty"), { fetch }) }, "An endpoint created with getRepositoryEndpoint does not throw").not.toThrow() + consoleWarnSpy.mockRestore() + process.env.NODE_ENV = originalNodeEnv }) -it("constructor warns if a non-.cdn prismic.io endpoint is given", () => { +it("warns in constructor if a non-.cdn prismic.io endpoint is given", () => { const fetch = vi.fn() const originalNodeEnv = process.env.NODE_ENV @@ -149,7 +155,83 @@ it("constructor warns if a non-.cdn prismic.io endpoint is given", () => { process.env.NODE_ENV = originalNodeEnv }) -it("constructor throws if fetch is unavailable", () => { +it("warns in constructor if an endpoint is given along the `documentAPIEndpoint` option and they do not match", () => { + const fetch = vi.fn() + + const originalNodeEnv = process.env.NODE_ENV + process.env.NODE_ENV = "development" + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0) + + prismic.createClient("https://example.com/my-repo-name", { + documentAPIEndpoint: "https://example.com/my-repo-name/prismic", + fetch, + }) + expect(consoleWarnSpy).toHaveBeenNthCalledWith( + 2, + expect.stringMatching(/prefer-repository-name/i), + ) + consoleWarnSpy.mockClear() + + prismic.createClient("https://example-prismic-repo.cdn.prismic.io/api/v2", { + documentAPIEndpoint: "https://example.com/my-repo-name/prismic", + fetch, + }) + expect(consoleWarnSpy).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/prefer-repository-name/i), + ) + consoleWarnSpy.mockClear() + + prismic.createClient("https://example.com/my-repo-name/prismic", { + documentAPIEndpoint: "https://example.com/my-repo-name/prismic", + fetch, + }) + expect(consoleWarnSpy).toHaveBeenNthCalledWith( + 1, + expect.stringMatching(/prefer-repository-name/i), + ) + consoleWarnSpy.mockClear() + + prismic.createClient("my-repo-name", { + documentAPIEndpoint: "https://example.com/my-repo-name/prismic", + fetch, + }) + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringMatching(/prefer-repository-name/i), + ) + + consoleWarnSpy.mockRestore() + + process.env.NODE_ENV = originalNodeEnv +}) + +it("warns in constructor if an endpoint is given and the repository name could not be inferred", () => { + const fetch = vi.fn() + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0) + + prismic.createClient("https://example.com/my-repo-name/prismic", { fetch }) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching(/prefer-repository-name/i), + ) + consoleWarnSpy.mockClear() + + prismic.createClient("https://example-prismic-repo.cdn.prismic.io/api/v2", { + fetch, + }) + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringMatching(/prefer-repository-name/i), + ) + + consoleWarnSpy.mockRestore() +}) + +it("throws in constructor if fetch is unavailable", () => { const endpoint = prismic.getRepositoryEndpoint("qwerty") const originalFetch = globalThis.fetch @@ -166,7 +248,7 @@ it("constructor throws if fetch is unavailable", () => { globalThis.fetch = originalFetch }) -it("constructor throws if provided fetch is not a function", () => { +it("throws in constructor if provided fetch is not a function", () => { const endpoint = prismic.getRepositoryEndpoint("qwerty") const fetch = "not a function" @@ -197,6 +279,51 @@ it("constructor throws if provided fetch is not a function", () => { globalThis.fetch = originalFetch }) +it("throws if `repositoryName` is not available but accessed", () => { + const fetch = vi.fn() + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0) + + const client = prismic.createClient( + "https://example.com/my-repo-name/prismic", + { fetch }, + ) + + expect(() => { + client.repositoryName + }).toThrowError(/prefer-repository-name/i) + + const client2 = prismic.createClient("my-repo-name", { + fetch, + documentAPIEndpoint: "https://example.com/my-repo-name/prismic", + }) + + expect(() => { + client2.repositoryName + }).not.toThrowError() + + consoleWarnSpy.mockRestore() +}) + +// TODO: Remove when alias gets removed +it("aliases `endpoint` (deprecated) to `documentAPIEndpoint`", () => { + const fetch = vi.fn() + + const client = prismic.createClient( + "https://example-prismic-repo.cdn.prismic.io/api/v2", + { fetch }, + ) + + expect(client.documentAPIEndpoint).toBe(client.endpoint) + + const otherEndpoint = "https://other-prismic-repo.cdn.prismic.io/api/v2" + client.endpoint = otherEndpoint + + expect(client.documentAPIEndpoint).toBe(otherEndpoint) +}) + it("uses globalThis.fetch if available", async () => { const endpoint = prismic.getRepositoryEndpoint("qwerty") const responseBody = { foo: "bar" } @@ -224,7 +351,7 @@ it("uses the master ref by default", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -240,7 +367,7 @@ it("supports manual string ref", async (ctx) => { ctx, }) - const client = createTestClient({ clientConfig: { ref } }) + const client = createTestClient({ clientConfig: { ref }, ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -256,7 +383,7 @@ it("supports manual thunk ref", async (ctx) => { ctx, }) - const client = createTestClient({ clientConfig: { ref: () => ref } }) + const client = createTestClient({ clientConfig: { ref: () => ref }, ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -273,7 +400,10 @@ it("uses master ref if ref thunk param returns non-string value", async (ctx) => ctx, }) - const client = createTestClient({ clientConfig: { ref: () => undefined } }) + const client = createTestClient({ + clientConfig: { ref: () => undefined }, + ctx, + }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -294,7 +424,7 @@ it("uses browser preview ref if available", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -318,7 +448,7 @@ it("uses req preview ref if available", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.get() @@ -342,7 +472,7 @@ it("supports req with Web APIs", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.get() @@ -364,7 +494,7 @@ it("ignores req without cookies", async (ctx) => { ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) client.enableAutoPreviewsFromReq(req) const res = await client.get() @@ -381,7 +511,7 @@ it("does not use preview ref if auto previews are disabled", async (ctx) => { const repositoryResponse = ctx.mock.api.repository() const queryResponse = prismicM.api.query({ seed: ctx.task.name }) - const client = createTestClient() + const client = createTestClient({ ctx }) // Disable auto previews and ensure the default ref is being used. Note that // the global cookie has already been set by this point, which should be @@ -430,7 +560,7 @@ it("uses the integration fields ref if the repository provides it", async (ctx) ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -450,7 +580,7 @@ it("ignores the integration fields ref if the repository provides a null value", ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -485,7 +615,7 @@ it("uses client-provided routes in queries", async (ctx) => { ctx, }) - const client = createTestClient({ clientConfig: { routes } }) + const client = createTestClient({ clientConfig: { routes }, ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -504,7 +634,7 @@ it("uses client-provided brokenRoute in queries", async (ctx) => { ctx, }) - const client = createTestClient({ clientConfig: { brokenRoute } }) + const client = createTestClient({ clientConfig: { brokenRoute }, ctx }) const res = await client.get() expect(res).toStrictEqual(queryResponse) @@ -516,7 +646,7 @@ it("throws ForbiddenError if access token is invalid for repository metadata", a ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(() => client.getRepository()).rejects.toThrowError( /invalid access token/i, @@ -532,7 +662,7 @@ it("throws ForbiddenError if access token is invalid for query", async (ctx) => ctx, }) - const client = createTestClient() + const client = createTestClient({ ctx }) await expect(() => client.get()).rejects.toThrowError(/invalid access token/i) await expect(() => client.get()).rejects.toThrowError(prismic.ForbiddenError) @@ -545,7 +675,7 @@ it("throws ForbiddenError if response code is 403", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "documents/search", @@ -583,7 +713,7 @@ it("throws ParsingError if response code is 400 with parsing-error type", async mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "documents/search", @@ -605,7 +735,7 @@ it("throws PrismicError if response code is 400 but is not a parsing error", asy mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "documents/search", @@ -626,7 +756,7 @@ it("throws PrismicError if response is not 200, 400, 401, 403, or 404", async (c mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "./documents/search", @@ -646,7 +776,7 @@ it("throws PrismicError if response is not 200, 400, 401, 403, or 404", async (c it("throws PrismicError if response is not JSON", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "documents/search", @@ -664,7 +794,7 @@ it("throws PrismicError if response is not JSON", async (ctx) => { }) it("throws RepositoryNotFoundError if repository does not exist", async (ctx) => { - const client = createTestClient() + const client = createTestClient({ ctx }) ctx.server.use( msw.rest.get(client.endpoint, (_req, res, ctx) => { @@ -686,7 +816,7 @@ it("throws RefNotFoundError if ref does not exist", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "./documents/search", @@ -713,7 +843,7 @@ it("throws RefExpiredError if ref is expired", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "./documents/search", @@ -740,7 +870,7 @@ it("throws PreviewTokenExpiredError if preview token is expired", async (ctx) => mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "./documents/search", @@ -767,7 +897,7 @@ it("throws NotFoundError if the 404 error is unknown", async (ctx) => { mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "./documents/search", @@ -802,7 +932,7 @@ it("retries after `retry-after` milliseconds if response code is 429", async (ct queryResponse, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "documents/search", @@ -865,7 +995,7 @@ it("retries after 1000 milliseconds if response code is 429 and an invalid `retr queryResponse, }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "documents/search", @@ -912,7 +1042,7 @@ it("throws if a non-2xx response is returned even after retrying", async (ctx) = mockPrismicRestAPIV2({ ctx }) - const client = createTestClient() + const client = createTestClient({ ctx }) const queryEndpoint = new URL( "documents/search", diff --git a/test/getRepositoryName.test.ts b/test/getRepositoryName.test.ts index 8f97db0a..6341f5e0 100644 --- a/test/getRepositoryName.test.ts +++ b/test/getRepositoryName.test.ts @@ -2,7 +2,7 @@ import { expect, it } from "vitest" import * as prismic from "../src" -it("returns the repository name from a valid Prismic Rest API V2 endpoint", () => { +it("returns the repository name from a valid Prismic Document API endpoint", () => { const repositoryName = prismic.getRepositoryName( "https://qwerty.cdn.prismic.io/api/v2", ) @@ -10,11 +10,22 @@ it("returns the repository name from a valid Prismic Rest API V2 endpoint", () = expect(repositoryName).toBe("qwerty") }) +it("throws if the input is not a valid Document API endpoint", () => { + expect(() => { + prismic.getRepositoryName("https://example.com") + }).toThrowError( + /An invalid Prismic Document API endpoint was provided: https:\/\/example\.com/i, + ) + expect(() => { + prismic.getRepositoryName("https://example.com") + }).toThrowError(prismic.PrismicError) +}) + it("throws if the input is not a valid URL", () => { expect(() => { prismic.getRepositoryName("qwerty") }).toThrowError( - /An invalid Prismic Rest API V2 endpoint was provided: qwerty/i, + /An invalid Prismic Document API endpoint was provided: qwerty/i, ) expect(() => { prismic.getRepositoryName("qwerty") diff --git a/test/helpers-mapSliceZone.test.ts b/test/helpers-mapSliceZone.test.ts index f7b71b24..f513fdba 100644 --- a/test/helpers-mapSliceZone.test.ts +++ b/test/helpers-mapSliceZone.test.ts @@ -42,8 +42,18 @@ it("maps a Slice Zone", async (ctx) => { }) expect(actual).toStrictEqual([ - { __mapped: true, id: undefined, slice_type: model1.id, foo: "bar" }, - { __mapped: true, id: undefined, slice_type: model2.id, baz: "qux" }, + { + __mapped: true, + id: expect.any(String), + slice_type: model1.id, + foo: "bar", + }, + { + __mapped: true, + id: expect.any(String), + slice_type: model2.id, + baz: "qux", + }, ]) }) @@ -56,8 +66,8 @@ it("supports mapping functions that return undefined", async (ctx) => { }) expect(actual).toStrictEqual([ - { __mapped: true, id: undefined, slice_type: model1.id }, - { __mapped: true, id: undefined, slice_type: model2.id }, + { __mapped: true, id: expect.any(String), slice_type: model1.id }, + { __mapped: true, id: expect.any(String), slice_type: model2.id }, ]) }) @@ -70,8 +80,18 @@ it("supports async mapping functions", async (ctx) => { }) expect(actual).toStrictEqual([ - { __mapped: true, id: undefined, slice_type: model1.id, foo: "bar" }, - { __mapped: true, id: undefined, slice_type: model2.id, baz: "qux" }, + { + __mapped: true, + id: expect.any(String), + slice_type: model1.id, + foo: "bar", + }, + { + __mapped: true, + id: expect.any(String), + slice_type: model2.id, + baz: "qux", + }, ]) }) @@ -156,8 +176,18 @@ it("supports lazy-loaded mapping functions", async (ctx) => { }) expect(actual).toStrictEqual([ - { __mapped: true, id: undefined, slice_type: model1.id, foo: "bar" }, - { __mapped: true, id: undefined, slice_type: model2.id, baz: "qux" }, + { + __mapped: true, + id: expect.any(String), + slice_type: model1.id, + foo: "bar", + }, + { + __mapped: true, + id: expect.any(String), + slice_type: model2.id, + baz: "qux", + }, ]) }) @@ -169,7 +199,7 @@ it("skips Slices without a mapping function", async (ctx) => { }) expect(actual).toStrictEqual([ - { __mapped: true, id: undefined, slice_type: model1.id }, + { __mapped: true, id: expect.any(String), slice_type: model1.id }, sliceZone[1], ]) }) diff --git a/test/migration-createAsset.test.ts b/test/migration-createAsset.test.ts new file mode 100644 index 00000000..9d8e205a --- /dev/null +++ b/test/migration-createAsset.test.ts @@ -0,0 +1,227 @@ +import { it as _it, expect } from "vitest" + +import * as prismic from "../src" +import type { Asset } from "../src/types/api/asset/asset" +import { AssetType } from "../src/types/api/asset/asset" + +// Skip test on Node 16 and 18 (File and FormData support) +const isNode16 = process.version.startsWith("v16") +const isNode18 = process.version.startsWith("v18") +const it = _it.skipIf(isNode16 || isNode18) + +it("creates an asset from a url", () => { + const migration = prismic.createMigration() + + const filename = "foo.jpg" + const file = "https://example.com/foo.jpg" + + migration.createAsset(file, filename) + + expect(migration._assets.get(file)?.config).toEqual({ + id: file, + file, + filename, + }) +}) + +it("creates an asset from a file", () => { + const migration = prismic.createMigration() + + const filename = "foo.jpg" + const file = new File(["data"], filename) + + migration.createAsset(file, filename) + + expect(migration._assets.get(file)?.config).toEqual({ + id: file, + file, + filename, + }) +}) + +it("creates an asset from an existing asset", () => { + const migration = prismic.createMigration() + + const asset: Asset = { + id: "foo", + url: "https://example.com/foo.jpg", + created_at: 0, + last_modified: 0, + filename: "foo.jpg", + extension: "jpg", + size: 1, + kind: AssetType.Image, + width: 1, + height: 1, + notes: "notes", + credits: "credits", + alt: "alt", + tags: [ + { + name: "tag", + id: "id", + created_at: 0, + last_modified: 0, + uploader_id: "uploader_id", + }, + ], + } + + migration.createAsset(asset) + + expect(migration._assets.get(asset.id)?.config).toStrictEqual({ + id: asset.id, + file: asset.url, + filename: asset.filename, + alt: asset.alt, + credits: asset.credits, + notes: asset.notes, + tags: asset.tags?.map(({ name }) => name), + }) +}) + +it("creates an asset from an image field", () => { + const migration = prismic.createMigration() + + const image: prismic.FilledImageFieldImage = { + id: "foo", + url: "https://example.com/foo.jpg", + alt: "alt", + copyright: "copyright", + dimensions: { width: 1, height: 1 }, + edit: { x: 0, y: 0, zoom: 1, background: "transparent" }, + } + + migration.createAsset(image) + + expect(migration._assets.get(image.id)?.config).toEqual({ + id: image.id, + file: image.url, + filename: image.url.split("/").pop(), + alt: image.alt, + credits: image.copyright, + }) +}) + +it("creates an asset from a link to media field", () => { + const migration = prismic.createMigration() + + const link: prismic.FilledLinkToMediaField = { + id: "foo", + url: "https://example.com/foo.jpg", + link_type: prismic.LinkType.Media, + kind: AssetType.Image, + name: "foo.jpg", + size: "1", + height: "1", + width: "1", + } + + migration.createAsset(link) + + expect(migration._assets.get(link.id)?.config).toEqual({ + id: link.id, + file: link.url, + filename: link.name, + }) +}) + +it("throws if asset has invalid metadata", () => { + const migration = prismic.createMigration() + + const filename = "foo.jpg" + const file = "https://example.com/foo.jpg" + + expect(() => { + migration.createAsset(file, filename, { notes: "0".repeat(501) }) + }, "notes").toThrowError(/`notes` must be at most 500 characters/i) + + expect(() => { + migration.createAsset(file, filename, { credits: "0".repeat(501) }) + }, "credits").toThrowError(/`credits` must be at most 500 characters/i) + + expect(() => { + migration.createAsset(file, filename, { alt: "0".repeat(501) }) + }, "alt").toThrowError(/`alt` must be at most 500 characters/i) + + expect(() => { + migration.createAsset(file, filename, { tags: ["0"] }) + }, "tags").toThrowError( + /tags must be at least 3 characters long and 20 characters at most/i, + ) + + expect(() => { + migration.createAsset(file, filename, { tags: ["0".repeat(21)] }) + }, "tags").toThrowError( + /tags must be at least 3 characters long and 20 characters at most/i, + ) + + expect(() => { + migration.createAsset(file, filename, { tags: ["012"] }) + }, "tags").not.toThrowError() +}) + +it("consolidates existing assets with additional metadata", () => { + const migration = prismic.createMigration() + + const filename = "foo.jpg" + const file = "https://example.com/foo.jpg" + + migration.createAsset(file, filename) + + expect(migration._assets.get(file)?.config).toStrictEqual({ + id: file, + file, + filename, + notes: undefined, + alt: undefined, + credits: undefined, + tags: undefined, + }) + + migration.createAsset(file, filename, { + notes: "notes", + alt: "alt", + credits: "credits", + tags: ["tag"], + }) + + expect(migration._assets.get(file)?.config).toStrictEqual({ + id: file, + file, + filename, + notes: "notes", + alt: "alt", + credits: "credits", + tags: ["tag"], + }) + + migration.createAsset(file, filename, { + notes: "notes 2", + alt: "alt 2", + credits: "credits 2", + tags: ["tag", "tag 2"], + }) + + expect(migration._assets.get(file)?.config).toStrictEqual({ + id: file, + file, + filename, + notes: "notes", + alt: "alt", + credits: "credits", + tags: ["tag", "tag 2"], + }) + + migration.createAsset(file, filename) + + expect(migration._assets.get(file)?.config).toStrictEqual({ + id: file, + file, + filename, + notes: "notes", + alt: "alt", + credits: "credits", + tags: ["tag", "tag 2"], + }) +}) diff --git a/test/migration-createDocument.test.ts b/test/migration-createDocument.test.ts new file mode 100644 index 00000000..3ee412c4 --- /dev/null +++ b/test/migration-createDocument.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest" + +import * as prismic from "../src" +import { PrismicMigrationDocument } from "../src/types/migration/Document" + +it("creates a document", () => { + const migration = prismic.createMigration() + + const document: prismic.PendingPrismicDocument = { + type: "type", + uid: "uid", + lang: "lang", + data: {}, + } + const documentTitle = "documentTitle" + + migration.createDocument(document, documentTitle) + + expect(migration._documents[0]).toStrictEqual( + new PrismicMigrationDocument(document, documentTitle), + ) +}) diff --git a/test/migration-createDocumentFromPrismic.test.ts b/test/migration-createDocumentFromPrismic.test.ts new file mode 100644 index 00000000..d453f022 --- /dev/null +++ b/test/migration-createDocumentFromPrismic.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it } from "vitest" + +import type { MockFactory } from "@prismicio/mock" + +import * as prismic from "../src" +import type { MigrationAssetConfig } from "../src/types/migration/Asset" +import { PrismicMigrationDocument } from "../src/types/migration/Document" + +it("creates a document from an existing Prismic document", (ctx) => { + const migration = prismic.createMigration() + + const document = ctx.mock.value.document() + const documentTitle = "documentTitle" + + const { type, uid, lang, tags, data } = document + + migration.createDocumentFromPrismic(document, documentTitle) + + expect(migration._documents[0]).toStrictEqual( + new PrismicMigrationDocument( + { type, uid, lang, tags, data }, + documentTitle, + { + originalPrismicDocument: document, + }, + ), + ) +}) + +describe.each<{ + fieldType: string + getField: (mock: MockFactory) => { + id: string + field: + | prismic.FilledImageFieldImage + | prismic.FilledLinkToMediaField + | prismic.RichTextField<"filled"> + expected: MigrationAssetConfig + } +}>([ + { + fieldType: "image", + getField: (mock) => { + const image = mock.value.image({ state: "filled" }) + + return { + id: image.id, + field: image, + expected: { + alt: image.alt || undefined, + credits: image.copyright || undefined, + file: image.url.split("?")[0], + filename: image.url.split("/").pop()!.split("?")[0], + id: image.id, + notes: undefined, + tags: undefined, + }, + } + }, + }, + { + fieldType: "link to media", + getField: (mock) => { + const linkToMedia = mock.value.linkToMedia({ state: "filled" }) + + return { + id: linkToMedia.id, + field: linkToMedia, + expected: { + alt: undefined, + credits: undefined, + file: linkToMedia.url.split("?")[0], + filename: linkToMedia.name, + id: linkToMedia.id, + notes: undefined, + tags: undefined, + }, + } + }, + }, + { + fieldType: "rich text (image)", + getField: (mock) => { + const image = mock.value.image({ state: "filled" }) + const richText: prismic.RichTextField<"filled"> = [ + { + type: prismic.RichTextNodeType.image, + ...image, + }, + ] + + return { + id: image.id, + field: richText, + expected: { + alt: image.alt || undefined, + credits: image.copyright || undefined, + file: image.url.split("?")[0], + filename: image.url.split("/").pop()!.split("?")[0], + id: image.id, + notes: undefined, + tags: undefined, + }, + } + }, + }, + { + fieldType: "rich text (link to media span)", + getField: (mock) => { + const linkToMedia = mock.value.linkToMedia({ state: "filled" }) + const richText = mock.value.richText({ + state: "filled", + }) as prismic.RichTextField<"filled"> + + richText.push({ + type: prismic.RichTextNodeType.paragraph, + text: "lorem", + spans: [ + { + start: 0, + end: 5, + type: prismic.RichTextNodeType.hyperlink, + data: linkToMedia, + }, + ], + }) + + return { + id: linkToMedia.id, + field: richText, + expected: { + alt: undefined, + credits: undefined, + file: linkToMedia.url.split("?")[0], + filename: linkToMedia.name, + id: linkToMedia.id, + notes: undefined, + tags: undefined, + }, + } + }, + }, + { + fieldType: "rich text (image's link to media)", + getField: (mock) => { + const image = mock.value.image({ state: "filled" }) + const linkToMedia = mock.value.linkToMedia({ state: "filled" }) + const richText: prismic.RichTextField<"filled"> = [ + { + type: prismic.RichTextNodeType.image, + linkTo: linkToMedia, + ...image, + }, + ] + + return { + id: linkToMedia.id, + field: richText, + expected: { + alt: undefined, + credits: undefined, + file: linkToMedia.url.split("?")[0], + filename: linkToMedia.name, + id: linkToMedia.id, + notes: undefined, + tags: undefined, + }, + } + }, + }, +])("extracts assets from image fields ($fieldType)", ({ getField }) => { + it("regular fields", ({ mock }) => { + const migration = prismic.createMigration() + + const { id, field, expected } = getField(mock) + + const document: prismic.PrismicDocument = mock.value.document() + document.data = { field } + const documentTitle = "documentTitle" + + expect(migration._assets.size).toBe(0) + + migration.createDocumentFromPrismic(document, documentTitle) + + expect(migration._assets.get(id)?.config).toStrictEqual(expected) + }) + + it("group fields", ({ mock }) => { + const migration = prismic.createMigration() + + const { id, field, expected } = getField(mock) + + const document: prismic.PrismicDocument = mock.value.document() + document.data = { group: [{ field }] } + const documentTitle = "documentTitle" + + expect(migration._assets.size).toBe(0) + + migration.createDocumentFromPrismic(document, documentTitle) + + expect(migration._assets.get(id)?.config).toStrictEqual(expected) + }) + + it("slice's primary zone", ({ mock }) => { + const migration = prismic.createMigration() + + const { id, field, expected } = getField(mock) + + const slices: prismic.SliceZone = [ + { + id: "id", + slice_type: "slice_type", + slice_label: "slice_label", + primary: { field }, + items: [], + }, + ] + + const document: prismic.PrismicDocument = mock.value.document() + document.data = { slices } + const documentTitle = "documentTitle" + + expect(migration._assets.size).toBe(0) + + migration.createDocumentFromPrismic(document, documentTitle) + + expect(migration._assets.get(id)?.config).toStrictEqual(expected) + }) + + it("slice's repeatable zone", ({ mock }) => { + const migration = prismic.createMigration() + + const { id, field, expected } = getField(mock) + + const slices: prismic.SliceZone = [ + { + id: "id", + slice_type: "slice_type", + slice_label: "slice_label", + primary: {}, + items: [{ field }], + }, + ] + + const document: prismic.PrismicDocument = mock.value.document() + document.data = { slices } + const documentTitle = "documentTitle" + + expect(migration._assets.size).toBe(0) + + migration.createDocumentFromPrismic(document, documentTitle) + + expect(migration._assets.get(id)?.config).toStrictEqual(expected) + }) +}) diff --git a/test/migration-getByUID.test.ts b/test/migration-getByUID.test.ts new file mode 100644 index 00000000..e60ad352 --- /dev/null +++ b/test/migration-getByUID.test.ts @@ -0,0 +1,35 @@ +import { expect, it } from "vitest" + +import * as prismic from "../src" + +it("returns a document with a matching UID", () => { + const migration = prismic.createMigration() + + const document = { + type: "type", + uid: "foo", + lang: "lang", + data: {}, + } + const documentName = "documentName" + + const doc = migration.createDocument(document, documentName) + + expect(migration.getByUID(document.type, document.uid)).toStrictEqual(doc) +}) + +it("returns `undefined` if a document is not found", () => { + const migration = prismic.createMigration() + + const document = { + type: "type", + uid: "foo", + lang: "lang", + data: {}, + } + const documentName = "documentName" + + migration.createDocument(document, documentName) + + expect(migration.getByUID(document.type, "bar")).toStrictEqual(undefined) +}) diff --git a/test/migration-getSingle.test.ts b/test/migration-getSingle.test.ts new file mode 100644 index 00000000..ff173cb5 --- /dev/null +++ b/test/migration-getSingle.test.ts @@ -0,0 +1,33 @@ +import { expect, it } from "vitest" + +import * as prismic from "../src" + +it("returns a document of a given singleton type", () => { + const migration = prismic.createMigration() + + const document = { + type: "foo", + lang: "lang", + data: {}, + } + const documentName = "documentName" + + const doc = migration.createDocument(document, documentName) + + expect(migration.getSingle(document.type)).toStrictEqual(doc) +}) + +it("returns `undefined` if a document is not found", () => { + const migration = prismic.createMigration() + + const document = { + type: "foo", + lang: "lang", + data: {}, + } + const documentName = "documentName" + + migration.createDocument(document, documentName) + + expect(migration.getSingle("bar")).toStrictEqual(undefined) +}) diff --git a/test/migration-updateDocument.test.ts b/test/migration-updateDocument.test.ts new file mode 100644 index 00000000..8c553c04 --- /dev/null +++ b/test/migration-updateDocument.test.ts @@ -0,0 +1,17 @@ +import { expect, it } from "vitest" + +import * as prismic from "../src" +import { PrismicMigrationDocument } from "../src/types/migration/Document" + +it("updates a document", (ctx) => { + const migration = prismic.createMigration() + + const document = ctx.mock.value.document() + const documentTitle = "documentTitle" + + migration.updateDocument(document, documentTitle) + + expect(migration._documents[0]).toStrictEqual( + new PrismicMigrationDocument(document, documentTitle), + ) +}) diff --git a/test/types/migration-document.types.ts b/test/types/migration-document.types.ts new file mode 100644 index 00000000..21d39a62 --- /dev/null +++ b/test/types/migration-document.types.ts @@ -0,0 +1,224 @@ +import type { TypeOf } from "ts-expect" +import { expectNever, expectType } from "ts-expect" + +import type * as prismic from "../../src" + +;(value: prismic.PendingPrismicDocument): true => { + switch (typeof value) { + case "object": { + if (value === null) { + expectNever(value) + } + + return true + } + + default: { + return expectNever(value) + } + } +} + +expectType({ + uid: "", + type: "", + lang: "", + data: {}, +}) + +/** + * Supports any field when generic. + */ +expectType({ + uid: "", + type: "", + lang: "", + data: { + any: "", + }, +}) + +/** + * `PrismicDocument` is assignable to `PendingPrismicDocument` with added + * `title`. + */ +expectType< + TypeOf< + prismic.PendingPrismicDocument, + prismic.PrismicDocument & { title: string } + > +>(true) + +// Migration Documents +type FooDocument = prismic.PrismicDocument, "foo"> +type BarDocument = prismic.PrismicDocument<{ bar: prismic.KeyTextField }, "bar"> +type BazDocument = prismic.PrismicDocument, "baz"> +type Documents = FooDocument | BarDocument | BazDocument + +type MigrationDocuments = prismic.PendingPrismicDocument + +/** + * Infers data type from document type. + */ +expectType({ + uid: "", + type: "foo", + lang: "", + data: {}, +}) + +// @ts-expect-error - `FooDocument` has no `bar` field in `data` +expectType({ + uid: "", + type: "foo", + lang: "", + data: { + bar: "", + }, +}) + +expectType({ + uid: "", + type: "bar", + lang: "", + data: { + bar: "", + }, +}) + +// @ts-expect-error - `bar` is missing in `data` +expectType({ + uid: "", + type: "bar", + lang: "", + data: {}, +}) + +/** + * Accepts migration field types. + */ +type Fields = { + image: prismic.ImageField + migrationImage: prismic.ImageField + linkToMedia: prismic.LinkToMediaField + migrationLinkToMedia: prismic.LinkToMediaField + contentRelationship: prismic.ContentRelationshipField + migrationContentRelationship: prismic.ContentRelationshipField + embedField: prismic.EmbedField + migrationEmbedField: prismic.EmbedField +} + +type StaticDocument = prismic.PrismicDocument +type GroupDocument = prismic.PrismicDocument< + { group: prismic.GroupField }, + "group" +> +type SliceDocument = prismic.PrismicDocument< + { + slices: prismic.SliceZone< + prismic.SharedSlice< + "default", + prismic.SharedSliceVariation< + "default", + Fields & { group: prismic.GroupField }, + Fields + > + > + > + }, + "slice" +> +type AdvancedDocuments = StaticDocument | GroupDocument | SliceDocument +type MigrationAdvancedDocuments = + prismic.PendingPrismicDocument + +// Static +expectType({ + uid: "", + type: "static", + lang: "", + data: { + image: {} as prismic.ImageField, + migrationImage: {} as prismic.MigrationImage, + linkToMedia: {} as prismic.LinkToMediaField, + migrationLinkToMedia: {} as prismic.MigrationLinkToMedia, + contentRelationship: {} as prismic.ContentRelationshipField, + migrationContentRelationship: {} as prismic.MigrationContentRelationship, + embedField: {} as prismic.EmbedField, + migrationEmbedField: { embed_url: "https://example.com" }, + }, +}) + +// Group +expectType({ + uid: "", + type: "group", + lang: "", + data: { + group: [ + { + image: {} as prismic.ImageField, + migrationImage: {} as prismic.MigrationImage, + linkToMedia: {} as prismic.LinkToMediaField, + migrationLinkToMedia: {} as prismic.MigrationLinkToMedia, + contentRelationship: {} as prismic.ContentRelationshipField, + migrationContentRelationship: + {} as prismic.MigrationContentRelationship, + embedField: {} as prismic.EmbedField, + migrationEmbedField: { embed_url: "https://example.com" }, + }, + ], + }, +}) + +// Slice +expectType({ + uid: "", + type: "slice", + lang: "", + data: { + slices: [ + { + slice_type: "default", + variation: "default", + primary: { + image: {} as prismic.ImageField, + migrationImage: {} as prismic.MigrationImage, + linkToMedia: {} as prismic.LinkToMediaField, + migrationLinkToMedia: {} as prismic.MigrationLinkToMedia, + contentRelationship: {} as prismic.ContentRelationshipField, + migrationContentRelationship: + {} as prismic.MigrationContentRelationship, + embedField: {} as prismic.EmbedField, + migrationEmbedField: { embed_url: "https://example.com" }, + group: [ + { + image: {} as prismic.ImageField, + migrationImage: {} as prismic.MigrationImage, + linkToMedia: {} as prismic.LinkToMediaField, + migrationLinkToMedia: {} as prismic.MigrationLinkToMedia, + contentRelationship: {} as prismic.ContentRelationshipField, + migrationContentRelationship: + {} as prismic.MigrationContentRelationship, + embedField: {} as prismic.EmbedField, + migrationEmbedField: { embed_url: "https://example.com" }, + }, + ], + }, + items: [ + { + image: {} as prismic.ImageField, + migrationImage: {} as prismic.MigrationImage, + linkToMedia: {} as prismic.LinkToMediaField, + migrationLinkToMedia: {} as prismic.MigrationLinkToMedia, + contentRelationship: {} as prismic.ContentRelationshipField, + migrationContentRelationship: + {} as prismic.MigrationContentRelationship, + embedField: {} as prismic.EmbedField, + migrationEmbedField: { embed_url: "https://example.com" }, + }, + ], + }, + ], + }, +}) diff --git a/test/types/migration.types.ts b/test/types/migration.types.ts new file mode 100644 index 00000000..04c69e83 --- /dev/null +++ b/test/types/migration.types.ts @@ -0,0 +1,497 @@ +import type { TypeEqual, TypeOf } from "ts-expect" +import { expectType } from "ts-expect" + +import * as prismic from "../../src" + +// Default migration +const defaultMigration = prismic.createMigration() + +// Migration Documents +type FooDocument = prismic.PrismicDocumentWithUID< + { foo: prismic.KeyTextField }, + "foo" +> +type BarDocument = prismic.PrismicDocumentWithUID< + { bar: prismic.KeyTextField }, + "bar" +> +type BazDocument = prismic.PrismicDocumentWithoutUID< + { baz: prismic.KeyTextField }, + "baz" +> +type Documents = FooDocument | BarDocument | BazDocument + +const documentsMigration = prismic.createMigration() + +/** + * Ensuring no full overlap between test types as we're testing for narrowing. + */ +expectType>(false) +expectType< + TypeEqual, prismic.Query> +>(false) +expectType>(false) + +expectType>(false) +expectType, prismic.Query>>( + false, +) +expectType>(false) + +expectType>(false) +expectType< + TypeEqual, prismic.Query> +>(false) +expectType>(false) + +/** + * createAsset + */ + +// Default +const defaultCreateAsset = defaultMigration.createAsset("url", "name") +expectType>(true) + +expectType>( + true, +) +expectType>( + true, +) + +// Documents +const documentsCreateAsset = defaultMigration.createAsset("url", "name") +expectType>(true) + +expectType< + TypeEqual +>(true) +expectType>( + true, +) + +/** + * createDocument + */ + +// Default +const defaultCreateDocument = defaultMigration.createDocument( + { + type: "", + uid: "", + lang: "", + data: {}, + }, + "", +) +expectType< + TypeEqual +>(true) + +// Documents +const documentsCreateDocument = documentsMigration.createDocument( + { + type: "foo", + uid: "", + lang: "", + data: { + foo: "", + }, + }, + "", +) +expectType< + TypeEqual< + typeof documentsCreateDocument, + prismic.PrismicMigrationDocument + > +>(true) + +documentsMigration.createDocument( + { + type: "baz", + lang: "", + data: { + baz: "", + }, + }, + "", +) + +documentsMigration.createDocument( + { + type: "baz", + // @ts-expect-error - Type 'string' is not assignable to type 'null | undefined'. + uid: "", + lang: "", + data: { + baz: "", + }, + }, + "", +) + +documentsMigration.createDocument( + { + type: "foo", + uid: "", + lang: "", + // @ts-expect-error - Property 'foo' is missing + data: {}, + }, + "", +) + +documentsMigration.createDocument( + { + type: "foo", + uid: "", + lang: "", + data: { + // @ts-expect-error - Type 'number' is not assignable to type 'KeyTextField' + foo: 1, + }, + }, + "", +) + +documentsMigration.createDocument( + { + type: "foo", + uid: "", + lang: "", + data: { + foo: "", + // @ts-expect-error - Object literal may only specify known properties + bar: "", + }, + }, + "", +) +/** + * updateDocument + */ + +// Default +const defaultUpdateDocument = defaultMigration.updateDocument( + { + id: "", + type: "", + lang: "", + data: {}, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) +expectType< + TypeEqual +>(true) + +// Documents +const documentsUpdateDocument = documentsMigration.updateDocument( + { + id: "", + type: "foo", + uid: "", + lang: "", + data: { + foo: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) +expectType< + TypeEqual< + typeof documentsUpdateDocument, + prismic.PrismicMigrationDocument + > +>(true) + +documentsMigration.updateDocument( + { + id: "", + type: "baz", + lang: "", + data: { + baz: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.updateDocument( + { + id: "", + type: "baz", + // @ts-expect-error - Type 'string' is not assignable to type 'null | undefined'. + uid: "", + lang: "", + data: { + baz: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.updateDocument( + { + id: "", + type: "foo", + uid: "", + lang: "", + // @ts-expect-error - Property 'foo' is missing + data: {}, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.updateDocument( + { + id: "", + type: "foo", + uid: "", + lang: "", + data: { + // @ts-expect-error - Type 'number' is not assignable to type 'KeyTextField' + foo: 1, + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.updateDocument( + { + id: "", + type: "foo", + uid: "", + lang: "", + data: { + foo: "", + // @ts-expect-error - Object literal may only specify known properties + bar: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +/** + * createDocumentFromPrismic + */ + +// Default +const defaultCreateFromPrismicDocument = + defaultMigration.createDocumentFromPrismic( + { + id: "", + type: "", + uid: "", + lang: "", + data: {}, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", + ) +expectType< + TypeEqual< + typeof defaultCreateFromPrismicDocument, + prismic.PrismicMigrationDocument + > +>(true) + +// Documents +const documentsCreateFromPrismicDocument = + documentsMigration.createDocumentFromPrismic( + { + id: "", + type: "foo", + uid: "", + lang: "", + data: { + foo: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", + ) +expectType< + TypeEqual< + typeof documentsCreateFromPrismicDocument, + prismic.PrismicMigrationDocument + > +>(true) + +documentsMigration.createDocumentFromPrismic( + { + id: "", + type: "baz", + lang: "", + data: { + baz: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.createDocumentFromPrismic( + { + id: "", + type: "baz", + // @ts-expect-error - Type 'string' is not assignable to type 'null | undefined'. + uid: "", + lang: "", + data: { + baz: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.createDocumentFromPrismic( + { + id: "", + type: "foo", + uid: "", + lang: "", + // @ts-expect-error - Property 'foo' is missing + data: {}, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.createDocumentFromPrismic( + { + id: "", + type: "foo", + uid: "", + lang: "", + data: { + // @ts-expect-error - Type 'number' is not assignable to type 'KeyTextField' + foo: 1, + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) + +documentsMigration.createDocumentFromPrismic( + { + id: "", + type: "foo", + uid: "", + lang: "", + data: { + foo: "", + // @ts-expect-error - Object literal may only specify known properties + bar: "", + }, + tags: [], + href: "", + url: "", + last_publication_date: `0-0-0T0:0:0+0`, + first_publication_date: `0-0-0T0:0:0+0`, + slugs: [], + alternate_languages: [], + linked_documents: [], + }, + "", +) diff --git a/test/writeClient-createAsset.test.ts b/test/writeClient-createAsset.test.ts new file mode 100644 index 00000000..cc05bdd0 --- /dev/null +++ b/test/writeClient-createAsset.test.ts @@ -0,0 +1,175 @@ +import { it as _it, expect } from "vitest" + +import { createTestWriteClient } from "./__testutils__/createWriteClient" +import { mockPrismicAssetAPI } from "./__testutils__/mockPrismicAssetAPI" + +import { ForbiddenError } from "../src" +import { UNKNOWN_RATE_LIMIT_DELAY } from "../src/BaseClient" +import type { AssetTag } from "../src/types/api/asset/tag" + +// Skip test on Node 16 and 18 (File and FormData support) +const isNode16 = process.version.startsWith("v16") +const isNode18 = process.version.startsWith("v18") +const it = _it.skipIf(isNode16 || isNode18) + +it.concurrent("creates an asset from string content", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicAssetAPI({ ctx, client }) + + // @ts-expect-error - testing purposes + const asset = await client.createAsset("file", "foo.jpg") + + expect(asset.id).toBeTypeOf("string") +}) + +it.concurrent("creates an asset from file content", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicAssetAPI({ ctx, client }) + + // @ts-expect-error - testing purposes + const asset = await client.createAsset( + new File(["file"], "foo.jpg"), + "foo.jpg", + ) + + expect(asset.id).toBeTypeOf("string") +}) + +it.concurrent("creates an asset with metadata", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicAssetAPI({ ctx, client }) + + // @ts-expect-error - testing purposes + const asset = await client.createAsset("file", "foo.jpg", { + alt: "foo", + credits: "bar", + notes: "baz", + }) + + // TODO: Check that data are properly passed when we update MSW and get FormData support + expect(asset.id).toBeTypeOf("string") +}) + +it.concurrent("creates an asset with an existing tag name", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const tag: AssetTag = { + id: "00000000-4444-4444-4444-121212121212", + name: "foo", + created_at: 0, + last_modified: 0, + } + + mockPrismicAssetAPI({ ctx, client, existingTags: [tag] }) + + // @ts-expect-error - testing purposes + const asset = await client.createAsset("file", "foo.jpg", { + tags: [tag.name], + }) + + expect(asset.tags?.[0].id).toEqual(tag.id) +}) + +it.concurrent("creates an asset with a new tag name", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const tag: AssetTag = { + id: "00000000-4444-4444-4444-121212121212", + name: "foo", + created_at: 0, + last_modified: 0, + } + + mockPrismicAssetAPI({ ctx, client, newTags: [tag] }) + + // @ts-expect-error - testing purposes + const asset = await client.createAsset("file", "foo.jpg", { + tags: ["foo"], + }) + + expect(asset.tags?.[0].id).toEqual(tag.id) +}) + +it.concurrent("throws forbidden error on invalid credentials", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicAssetAPI({ ctx, client, writeToken: "invalid" }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createAsset("file", "foo.jpg"), + ).rejects.toThrow(ForbiddenError) +}) + +// It seems like p-limit and node-fetch are not happy friends :/ +// https://github.com/sindresorhus/p-limit/issues/64 +it.skip("is abortable with an AbortController", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const controller = new AbortController() + controller.abort() + + mockPrismicAssetAPI({ ctx, client }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createAsset("file", "foo.jpg", { + fetchOptions: { signal: controller.signal }, + }), + ).rejects.toThrow(/aborted/i) +}) + +it.concurrent("supports custom headers", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const headers = { "x-custom": "foo" } + + mockPrismicAssetAPI({ ctx, client, requiredHeaders: headers }) + + // @ts-expect-error - testing purposes + const asset = await client.createAsset("file", "foo.jpg", { + fetchOptions: { headers }, + }) + + ctx.expect(asset.id).toBeTypeOf("string") + ctx.expect.assertions(2) +}) + +it.concurrent("respects unknown rate limit", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicAssetAPI({ ctx, client }) + + const args = ["file", "foo.jpg"] + + const start = Date.now() + + // @ts-expect-error - testing purposes + await client.createAsset(...args) + + expect(Date.now() - start).toBeLessThan(UNKNOWN_RATE_LIMIT_DELAY) + + // @ts-expect-error - testing purposes + await client.createAsset(...args) + + expect(Date.now() - start).toBeGreaterThanOrEqual(UNKNOWN_RATE_LIMIT_DELAY) +}) + +it("throws fetch errors as-is", async (ctx) => { + const client = createTestWriteClient({ + ctx, + clientConfig: { + fetch: () => { + throw new Error(ctx.task.name) + }, + }, + }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createAsset("foo", "foo.png"), + ).rejects.toThrowError(ctx.task.name) +}) diff --git a/test/writeClient-createAssetTag.test.ts b/test/writeClient-createAssetTag.test.ts new file mode 100644 index 00000000..674773f0 --- /dev/null +++ b/test/writeClient-createAssetTag.test.ts @@ -0,0 +1,123 @@ +import { it as _it, expect } from "vitest" + +import { createTestWriteClient } from "./__testutils__/createWriteClient" +import { mockPrismicAssetAPI } from "./__testutils__/mockPrismicAssetAPI" + +import { ForbiddenError } from "../src" +import { UNKNOWN_RATE_LIMIT_DELAY } from "../src/BaseClient" +import type { AssetTag } from "../src/types/api/asset/tag" + +// Skip test on Node 16 (FormData support) +const isNode16 = process.version.startsWith("v16") +const it = _it.skipIf(isNode16) + +it.concurrent("creates a tag", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const newTag: AssetTag = { + id: "00000000-4444-4444-4444-121212121212", + name: "foo", + created_at: 0, + last_modified: 0, + } + + mockPrismicAssetAPI({ ctx, client, newTags: [newTag] }) + + // @ts-expect-error - testing purposes + const tag = await client.createAssetTag(newTag.name) + + expect(tag).toStrictEqual(newTag) +}) + +it.concurrent("throws forbidden error on invalid credentials", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicAssetAPI({ ctx, client, writeToken: "invalid" }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createAssetTag("foo"), + ).rejects.toThrow(ForbiddenError) +}) + +// It seems like p-limit and node-fetch are not happy friends :/ +// https://github.com/sindresorhus/p-limit/issues/64 +it.skip("is abortable with an AbortController", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const controller = new AbortController() + controller.abort() + + mockPrismicAssetAPI({ ctx, client }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createAssetTag("foo", { + fetchOptions: { signal: controller.signal }, + }), + ).rejects.toThrow(/aborted/i) +}) + +it.concurrent("supports custom headers", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const headers = { "x-custom": "foo" } + + const newTag: AssetTag = { + id: "00000000-4444-4444-4444-121212121212", + name: "foo", + created_at: 0, + last_modified: 0, + } + + mockPrismicAssetAPI({ + ctx, + client, + requiredHeaders: headers, + newTags: [newTag], + }) + + // @ts-expect-error - testing purposes + const tag = await client.createAssetTag(newTag.name, { + fetchOptions: { headers }, + }) + + ctx.expect(tag).toStrictEqual(newTag) + ctx.expect.assertions(2) +}) + +it.concurrent("respects unknown rate limit", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicAssetAPI({ ctx, client }) + + const args = ["foo"] + + const start = Date.now() + + // @ts-expect-error - testing purposes + await client.createAssetTag(...args) + + expect(Date.now() - start).toBeLessThan(UNKNOWN_RATE_LIMIT_DELAY) + + // @ts-expect-error - testing purposes + await client.createAssetTag(...args) + + expect(Date.now() - start).toBeGreaterThanOrEqual(UNKNOWN_RATE_LIMIT_DELAY) +}) + +it("throws fetch errors as-is", async (ctx) => { + const client = createTestWriteClient({ + ctx, + clientConfig: { + fetch: () => { + throw new Error(ctx.task.name) + }, + }, + }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createAssetTag("foo"), + ).rejects.toThrowError(ctx.task.name) +}) diff --git a/test/writeClient-createDocument.test.ts b/test/writeClient-createDocument.test.ts new file mode 100644 index 00000000..8a8a1574 --- /dev/null +++ b/test/writeClient-createDocument.test.ts @@ -0,0 +1,153 @@ +import { expect, it } from "vitest" + +import { createTestWriteClient } from "./__testutils__/createWriteClient" +import { mockPrismicMigrationAPI } from "./__testutils__/mockPrismicMigrationAPI" + +import { ForbiddenError } from "../src" +import { UNKNOWN_RATE_LIMIT_DELAY } from "../src/BaseClient" + +it.concurrent("creates a document", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const newDocument = { id: "foo" } + + mockPrismicMigrationAPI({ ctx, client, newDocuments: [newDocument] }) + + // @ts-expect-error - testing purposes + const { id } = await client.createDocument( + { + type: "type", + uid: "uid", + lang: "lang", + data: {}, + }, + "Foo", + ) + + expect(id).toBe(newDocument.id) +}) + +it.concurrent("throws forbidden error on invalid credentials", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicMigrationAPI({ ctx, client, writeToken: "invalid" }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createDocument( + { + type: "type", + uid: "uid", + lang: "lang", + data: {}, + }, + "Foo", + ), + ).rejects.toThrow(ForbiddenError) +}) + +// It seems like p-limit and node-fetch are not happy friends :/ +// https://github.com/sindresorhus/p-limit/issues/64 +it.skip("is abortable with an AbortController", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const controller = new AbortController() + controller.abort() + + mockPrismicMigrationAPI({ ctx, client }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createDocument( + { + type: "type", + uid: "uid", + lang: "lang", + data: {}, + }, + "Foo", + { fetchOptions: { signal: controller.signal } }, + ), + ).rejects.toThrow(/aborted/i) +}) + +it.concurrent("supports custom headers", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const headers = { "x-custom": "foo" } + const newDocument = { id: "foo" } + + mockPrismicMigrationAPI({ + ctx, + client, + requiredHeaders: headers, + newDocuments: [newDocument], + }) + + // @ts-expect-error - testing purposes + const { id } = await client.createDocument( + { + type: "type", + uid: "uid", + lang: "lang", + data: {}, + }, + "Foo", + { fetchOptions: { headers } }, + ) + + ctx.expect(id).toBe(newDocument.id) + ctx.expect.assertions(2) +}) + +it.concurrent("respects unknown rate limit", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicMigrationAPI({ ctx, client }) + + const args = [ + { + type: "type", + uid: "uid", + lang: "lang", + data: {}, + }, + "Foo", + ] + + const start = Date.now() + + // @ts-expect-error - testing purposes + await client.createDocument(...args) + + expect(Date.now() - start).toBeLessThan(UNKNOWN_RATE_LIMIT_DELAY) + + // @ts-expect-error - testing purposes + await client.createDocument(...args) + + expect(Date.now() - start).toBeGreaterThanOrEqual(UNKNOWN_RATE_LIMIT_DELAY) +}) + +it("throws fetch errors as-is", async (ctx) => { + const client = createTestWriteClient({ + ctx, + clientConfig: { + fetch: () => { + throw new Error(ctx.task.name) + }, + }, + }) + + await expect(() => + // @ts-expect-error - testing purposes + client.createDocument( + { + type: "type", + uid: "uid", + lang: "lang", + data: {}, + }, + "Foo", + ), + ).rejects.toThrowError(ctx.task.name) +}) diff --git a/test/writeClient-fetchForeignAsset.test.ts b/test/writeClient-fetchForeignAsset.test.ts new file mode 100644 index 00000000..10249ec8 --- /dev/null +++ b/test/writeClient-fetchForeignAsset.test.ts @@ -0,0 +1,134 @@ +import { it as _it, expect } from "vitest" + +import { rest } from "msw" + +import { createTestWriteClient } from "./__testutils__/createWriteClient" + +// Skip test on Node 16 and 18 (File and FormData support) +const isNode16 = process.version.startsWith("v16") +const isNode18 = process.version.startsWith("v18") +const it = _it.skipIf(isNode16 || isNode18) + +it.concurrent("fetches a foreign asset with content type", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const url = `https://example.com/${client.repositoryName}.jpg` + + ctx.server.use( + rest.get(url, (_req, res, ctx) => { + return res(ctx.set("content-type", "image/png"), ctx.body("foo")) + }), + ) + + // @ts-expect-error - testing purposes + const blob = await client.fetchForeignAsset(url) + + expect(blob.type).toBe("image/png") +}) + +it.concurrent("fetches a foreign asset with no content type", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const url = `https://example.com/${client.repositoryName}.jpg` + + ctx.server.use( + rest.get(url, (_req, res, ctx) => { + return res(ctx.set("content-type", ""), ctx.body("foo")) + }), + ) + + // @ts-expect-error - testing purposes + const blob = await client.fetchForeignAsset(url) + + expect(blob.type).toBe("") +}) + +it.concurrent("is abortable with an AbortController", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const controller = new AbortController() + controller.abort() + const url = `https://example.com/${client.repositoryName}.jpg` + + ctx.server.use( + rest.get(url, (_req, res, ctx) => { + return res(ctx.set("content-type", ""), ctx.body("foo")) + }), + ) + + await expect(() => + // @ts-expect-error - testing purposes + client.fetchForeignAsset(url, { + fetchOptions: { signal: controller.signal }, + }), + ).rejects.toThrow(/aborted/i) +}) + +it.concurrent("is abortable with a global AbortController", async (ctx) => { + const controller = new AbortController() + controller.abort() + const client = createTestWriteClient({ + ctx, + clientConfig: { fetchOptions: { signal: controller.signal } }, + }) + + const url = `https://example.com/${client.repositoryName}.jpg` + + ctx.server.use( + rest.get(url, (_req, res, ctx) => { + return res(ctx.set("content-type", ""), ctx.body("foo")) + }), + ) + + await expect(() => + // @ts-expect-error - testing purposes + client.fetchForeignAsset(url), + ).rejects.toThrow(/aborted/i) +}) + +it.concurrent("supports custom headers", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const headers = { "x-custom": "foo" } + const url = `https://example.com/${client.repositoryName}.jpg` + + ctx.server.use( + rest.get(url, (req, res, restCtx) => { + ctx.expect(req.headers.get("x-custom")).toBe("foo") + + return res(restCtx.text("foo")) + }), + ) + + // @ts-expect-error - testing purposes + const blob = await client.fetchForeignAsset(url, { + fetchOptions: { headers }, + }) + + ctx.expect(blob.type).toBe("text/plain") + ctx.expect.assertions(2) +}) + +it.concurrent("supports global custom headers", async (ctx) => { + const headers = { "x-custom": "foo" } + const client = createTestWriteClient({ + ctx, + clientConfig: { fetchOptions: { headers } }, + }) + + const url = `https://example.com/${client.repositoryName}.jpg` + + ctx.server.use( + rest.get(url, (req, res, restCtx) => { + ctx.expect(req.headers.get("x-custom")).toBe("foo") + + return res(restCtx.text("foo")) + }), + ) + + // @ts-expect-error - testing purposes + const blob = await client.fetchForeignAsset(url) + + ctx.expect(blob.type).toBe("text/plain") + ctx.expect.assertions(2) +}) diff --git a/test/writeClient-migrate-assets.test.ts b/test/writeClient-migrate-assets.test.ts new file mode 100644 index 00000000..e9ac1a0c --- /dev/null +++ b/test/writeClient-migrate-assets.test.ts @@ -0,0 +1,350 @@ +import { it as _it, expect, vi } from "vitest" + +import { rest } from "msw" + +import { createTestWriteClient } from "./__testutils__/createWriteClient" +import { + mockAsset, + mockPrismicAssetAPI, +} from "./__testutils__/mockPrismicAssetAPI" +import { mockPrismicMigrationAPI } from "./__testutils__/mockPrismicMigrationAPI" +import { mockPrismicRestAPIV2 } from "./__testutils__/mockPrismicRestAPIV2" + +import * as prismic from "../src" + +// Skip test on Node 16 and 18 (File and FormData support) +const isNode16 = process.version.startsWith("v16") +const isNode18 = process.version.startsWith("v18") +const it = _it.skipIf(isNode16 || isNode18) + +it.concurrent("creates new asset from string file data", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + const dummyFileData = "foo" + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + newAssets: [asset], + }) + mockPrismicMigrationAPI({ ctx, client }) + + const migration = prismic.createMigration() + const migrationAsset = migration.createAsset(dummyFileData, asset.filename) + + const reporter = vi.fn() + + expect(assetsDatabase.flat()).toHaveLength(0) + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:creating", + data: { + current: 1, + remaining: 0, + total: 1, + asset: migrationAsset, + }, + }) + expect(assetsDatabase.flat()).toHaveLength(1) +}) + +it.concurrent("creates new asset from a File instance", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + const dummyFile = new File(["foo"], asset.filename) + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + newAssets: [asset], + }) + mockPrismicMigrationAPI({ ctx, client }) + + const migration = prismic.createMigration() + const migrationAsset = migration.createAsset(dummyFile, asset.filename) + + const reporter = vi.fn() + + expect(assetsDatabase.flat()).toHaveLength(0) + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:creating", + data: { + current: 1, + remaining: 0, + total: 1, + asset: migrationAsset, + }, + }) + expect(assetsDatabase.flat()).toHaveLength(1) +}) + +it.concurrent( + "creates new asset from a file URL with content type", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + newAssets: [asset], + }) + mockPrismicMigrationAPI({ ctx, client }) + + ctx.server.use( + rest.get(asset.url.split("?")[0], (_req, res, ctx) => { + return res(ctx.set("content-type", "image/jpg"), ctx.text("foo")) + }), + ) + + const migration = prismic.createMigration() + const migrationAsset = migration.createAsset( + new URL(asset.url), + asset.filename, + ) + + const reporter = vi.fn() + + expect(assetsDatabase.flat()).toHaveLength(0) + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:creating", + data: { + current: 1, + remaining: 0, + total: 1, + asset: migrationAsset, + }, + }) + expect(assetsDatabase.flat()).toHaveLength(1) + }, +) + +it.concurrent( + "creates new asset from a file string URL with content type", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + newAssets: [asset], + }) + mockPrismicMigrationAPI({ ctx, client }) + + ctx.server.use( + rest.get(asset.url.split("?")[0], (_req, res, ctx) => { + return res(ctx.set("content-type", "image/jpg"), ctx.text("foo")) + }), + ) + + const migration = prismic.createMigration() + const migrationAsset = migration.createAsset(asset.url, asset.filename) + + const reporter = vi.fn() + + expect(assetsDatabase.flat()).toHaveLength(0) + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:creating", + data: { + current: 1, + remaining: 0, + total: 1, + asset: migrationAsset, + }, + }) + expect(assetsDatabase.flat()).toHaveLength(1) + }, +) + +it.concurrent( + "creates new asset from a file URL with no content type", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + newAssets: [asset], + }) + mockPrismicMigrationAPI({ ctx, client }) + + ctx.server.use( + rest.get(asset.url.split("?")[0], (_req, res, ctx) => { + return res(ctx.body("foo")) + }), + ) + + const migration = prismic.createMigration() + const migrationAsset = migration.createAsset( + new URL(asset.url), + asset.filename, + ) + + const reporter = vi.fn() + + expect(assetsDatabase.flat()).toHaveLength(0) + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:creating", + data: { + current: 1, + remaining: 0, + total: 1, + asset: migrationAsset, + }, + }) + expect(assetsDatabase.flat()).toHaveLength(1) + }, +) + +it.concurrent( + "creates new asset from a file string URL with no content type", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + newAssets: [asset], + }) + mockPrismicMigrationAPI({ ctx, client }) + + ctx.server.use( + rest.get(asset.url.split("?")[0], (_req, res, ctx) => { + return res(ctx.text("foo")) + }), + ) + + const migration = prismic.createMigration() + const migrationAsset = migration.createAsset(asset.url, asset.filename) + + const reporter = vi.fn() + + expect(assetsDatabase.flat()).toHaveLength(0) + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:creating", + data: { + current: 1, + remaining: 0, + total: 1, + asset: migrationAsset, + }, + }) + expect(assetsDatabase.flat()).toHaveLength(1) + }, +) + +it.concurrent("creates new asset with metadata", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const assetMetadata = { + notes: "foo", + alt: "bar", + credits: "baz", + } + const asset = mockAsset(ctx, assetMetadata) + const dummyFileData = "foo" + + const existingTags = [ + { + id: "00000000-4444-4444-4444-121212121212", + name: "qux", + created_at: 0, + last_modified: 0, + }, + ] + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase, tagsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + existingTags, + newAssets: [asset], + }) + mockPrismicMigrationAPI({ ctx, client }) + + const migration = prismic.createMigration() + migration.createAsset(dummyFileData, asset.filename, { + ...assetMetadata, + tags: ["qux", "quux"], + }) + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + const newAsset = assetsDatabase[0][0] + expect(newAsset.notes).toBe(assetMetadata.notes) + expect(newAsset.alt).toBe(assetMetadata.alt) + expect(newAsset.credits).toBe(assetMetadata.credits) + expect(newAsset.tags).toStrictEqual(tagsDatabase) +}) + +it.concurrent( + "throws on fetch error when creating a new asset from a file URL", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + + mockPrismicRestAPIV2({ ctx }) + mockPrismicAssetAPI({ ctx, client, newAssets: [asset] }) + mockPrismicMigrationAPI({ ctx, client }) + + ctx.server.use( + rest.get(asset.url.split("?")[0], (_req, res, ctx) => { + return res(ctx.status(429)) + }), + ) + + const migration = prismic.createMigration() + const migrationAsset = migration.createAsset(asset.url, asset.filename) + + const reporter = vi.fn() + + await expect(() => + client.migrate(migration, { reporter }), + ).rejects.toThrowError(/could not fetch foreign asset/i) + + expect(reporter).toHaveBeenLastCalledWith({ + type: "assets:creating", + data: { + current: 1, + remaining: 0, + total: 1, + asset: migrationAsset, + }, + }) + }, +) diff --git a/test/writeClient-migrate-documents.test.ts b/test/writeClient-migrate-documents.test.ts new file mode 100644 index 00000000..ccfc6d49 --- /dev/null +++ b/test/writeClient-migrate-documents.test.ts @@ -0,0 +1,415 @@ +import type { TestContext } from "vitest" +import { it as _it, expect, vi } from "vitest" + +import { createPagedQueryResponses } from "./__testutils__/createPagedQueryResponses" +import { createTestWriteClient } from "./__testutils__/createWriteClient" +import { mockPrismicAssetAPI } from "./__testutils__/mockPrismicAssetAPI" +import { mockPrismicMigrationAPI } from "./__testutils__/mockPrismicMigrationAPI" +import { mockPrismicRestAPIV2 } from "./__testutils__/mockPrismicRestAPIV2" + +import * as prismic from "../src" + +// Skip test on Node 16 and 18 (File and FormData support) +const isNode16 = process.version.startsWith("v16") +const isNode18 = process.version.startsWith("v18") +const it = _it.skipIf(isNode16 || isNode18) + +const createRepository = ( + ctx: TestContext, + masterLocale = "en-us", +): { + repository: prismic.Repository + masterLocale: string +} => { + const repository = ctx.mock.api.repository() + repository.languages[0].id = masterLocale + repository.languages[0].is_master = true + + return { repository, masterLocale } +} + +it.concurrent("infers master locale", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { repository, masterLocale } = createRepository(ctx) + + mockPrismicRestAPIV2({ ctx, repositoryResponse: repository }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client }) + + const migration = prismic.createMigration() + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "documents:masterLocale", + data: { masterLocale }, + }) +}) + +it.concurrent("skips creating existing documents", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const queryResponse = createPagedQueryResponses({ + ctx, + pages: 1, + pageSize: 1, + }) + const document = queryResponse[0].results[0] + + mockPrismicRestAPIV2({ ctx, queryResponse }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, existingDocuments: [document] }) + + const migration = prismic.createMigration() + + migration.updateDocument(document, "foo") + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "documents:created", + data: { + created: 0, + }, + }) +}) + +it.concurrent("creates new documents", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { id: _id, ...document } = ctx.mock.value.document() + const newDocument = { id: "foo" } + + mockPrismicRestAPIV2({ ctx }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ + ctx, + client, + newDocuments: [newDocument], + }) + + const migration = prismic.createMigration() + + const doc = migration.createDocument(document, "foo") + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "documents:creating", + data: { + current: 1, + remaining: 0, + total: 1, + document: doc, + }, + }) + expect(doc.document.id).toBe(newDocument.id) +}) + +it.concurrent( + "creates new non-master locale document with direct reference (existing document)", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const queryResponse = createPagedQueryResponses({ + ctx, + pages: 1, + pageSize: 1, + }) + + const masterLanguageDocument = queryResponse[0].results[0] + const { id: _id, ...document } = ctx.mock.value.document() + const newDocument = { + id: "foo", + masterLanguageDocumentID: masterLanguageDocument.id, + } + + mockPrismicRestAPIV2({ ctx, queryResponse }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, newDocuments: [newDocument] }) + + const migration = prismic.createMigration() + + const doc = migration.createDocument(document, "foo", { + masterLanguageDocument, + }) + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + ctx.expect(reporter).toHaveBeenCalledWith({ + type: "documents:creating", + data: { + current: 1, + remaining: 0, + total: 1, + document: doc, + }, + }) + ctx.expect(migration._documents[0].document.id).toBe(newDocument.id) + ctx.expect.assertions(3) + }, +) + +it.concurrent( + "creates new non-master locale document with direct reference (new document)", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { id: masterLanguageDocumentID, ...masterLanguageDocument } = + ctx.mock.value.document() + const { id: documentID, ...document } = ctx.mock.value.document() + const newDocuments = [ + { + id: masterLanguageDocumentID, + }, + { + id: documentID, + masterLanguageDocumentID: masterLanguageDocumentID, + }, + ] + + mockPrismicRestAPIV2({ ctx }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, newDocuments: [...newDocuments] }) + + const migration = prismic.createMigration() + + const masterLanguageMigrationDocument = migration.createDocument( + masterLanguageDocument, + "foo", + ) + const doc = migration.createDocument(document, "bar", { + masterLanguageDocument: masterLanguageMigrationDocument, + }) + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + ctx + .expect(masterLanguageMigrationDocument.document.id) + .toBe(newDocuments[0].id) + ctx.expect(doc.document.id).toBe(newDocuments[1].id) + ctx.expect.assertions(3) + }, +) + +it.concurrent( + "creates new non-master locale document with lazy reference (existing document)", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const queryResponse = createPagedQueryResponses({ + ctx, + pages: 1, + pageSize: 1, + }) + + const masterLanguageDocument = queryResponse[0].results[0] + const { id: _id, ...document } = ctx.mock.value.document() + const newDocument = { + id: "foo", + masterLanguageDocumentID: masterLanguageDocument.id, + } + + mockPrismicRestAPIV2({ ctx, queryResponse }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, newDocuments: [newDocument] }) + + const migration = prismic.createMigration() + + const doc = migration.createDocument(document, "foo", { + masterLanguageDocument: () => masterLanguageDocument, + }) + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + ctx.expect(reporter).toHaveBeenCalledWith({ + type: "documents:creating", + data: { + current: 1, + remaining: 0, + total: 1, + document: doc, + }, + }) + ctx.expect(doc.document.id).toBe(newDocument.id) + ctx.expect.assertions(3) + }, +) + +it.concurrent( + "creates new non-master locale document with lazy reference (new document)", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { id: masterLanguageDocumentID, ...masterLanguageDocument } = + ctx.mock.value.document() + const { id: documentID, ...document } = ctx.mock.value.document() + const newDocuments = [ + { + id: masterLanguageDocumentID, + }, + { + id: documentID, + masterLanguageDocumentID: masterLanguageDocumentID, + }, + ] + + mockPrismicRestAPIV2({ ctx }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, newDocuments: [...newDocuments] }) + + const migration = prismic.createMigration() + + const masterLanguageMigrationDocument = migration.createDocument( + masterLanguageDocument, + "foo", + ) + const doc = migration.createDocument(document, "bar", { + masterLanguageDocument: () => masterLanguageMigrationDocument, + }) + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + ctx + .expect(masterLanguageMigrationDocument.document.id) + .toBe(newDocuments[0].id) + ctx.expect(doc.document.id).toBe(newDocuments[1].id) + ctx.expect.assertions(3) + }, +) + +it.concurrent( + "creates new non-master locale document with lazy reference (empty)", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { id: documentID, ...document } = ctx.mock.value.document() + const newDocuments = [ + { + id: documentID, + masterLanguageDocumentID: undefined, + }, + ] + + mockPrismicRestAPIV2({ ctx }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, newDocuments: [...newDocuments] }) + + const migration = prismic.createMigration() + + const doc = migration.createDocument(document, "bar", { + masterLanguageDocument: () => undefined, + }) + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + ctx.expect(doc.document.id).toBe(newDocuments[0].id) + ctx.expect.assertions(1) + }, +) + +it.concurrent( + "creates new non-master locale document with alternate language", + async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { repository, masterLocale } = createRepository(ctx) + + const masterLanguageDocument = ctx.mock.value.document() + masterLanguageDocument.lang = masterLocale + const document = ctx.mock.value.document({ + alternateLanguages: [masterLanguageDocument], + }) + const newDocuments = [ + { + id: "foo", + }, + { + id: "bar", + masterLanguageDocumentID: "foo", + }, + ] + + mockPrismicRestAPIV2({ ctx, repositoryResponse: repository }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, newDocuments }) + + const migration = prismic.createMigration() + + migration.createDocumentFromPrismic(masterLanguageDocument, "foo") + const doc = migration.createDocumentFromPrismic(document, "bar") + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + ctx.expect(reporter).toHaveBeenCalledWith({ + type: "documents:creating", + data: { + current: 2, + remaining: 0, + total: 2, + document: doc, + }, + }) + ctx.expect(doc.document.id).toBe("bar") + ctx.expect.assertions(3) + }, +) + +it.concurrent("creates master locale documents first", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { repository, masterLocale } = createRepository(ctx) + + const { id: masterLanguageDocumentID, ...masterLanguageDocument } = + ctx.mock.value.document() + masterLanguageDocument.lang = masterLocale + const { id: documentID, ...document } = ctx.mock.value.document() + const newDocuments = [ + { + id: masterLanguageDocumentID, + }, + { + id: documentID, + }, + ] + + mockPrismicRestAPIV2({ ctx, repositoryResponse: repository }) + mockPrismicAssetAPI({ ctx, client }) + mockPrismicMigrationAPI({ ctx, client, newDocuments: [...newDocuments] }) + + const migration = prismic.createMigration() + + const doc = migration.createDocument(document, "bar") + const masterLanguageMigrationDocument = migration.createDocument( + masterLanguageDocument, + "foo", + ) + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + ctx + .expect(masterLanguageMigrationDocument.document.id) + .toBe(newDocuments[0].id) + ctx.expect(doc.document.id).toBe(newDocuments[1].id) +}) diff --git a/test/writeClient-migrate-patch-contentRelationship.test.ts b/test/writeClient-migrate-patch-contentRelationship.test.ts new file mode 100644 index 00000000..3036480c --- /dev/null +++ b/test/writeClient-migrate-patch-contentRelationship.test.ts @@ -0,0 +1,107 @@ +import { testMigrationFieldPatching } from "./__testutils__/testMigrationFieldPatching" + +import type { + ContentRelationshipField, + InjectMigrationSpecificTypes, + MigrationContentRelationship, + RichTextField, +} from "../src" +import { LinkType, RichTextNodeType } from "../src" + +testMigrationFieldPatching< + MigrationContentRelationship | InjectMigrationSpecificTypes +>("patches content relationship fields", { + existing: ({ existingDocuments }) => existingDocuments[0], + existingLongForm: ({ existingDocuments }) => { + return { + link_type: LinkType.Document, + id: existingDocuments[0], + } + }, + existingLongFormWithText: ({ existingDocuments }) => { + return { + link_type: LinkType.Document, + id: existingDocuments[0], + text: "foo", + } + }, + otherCreate: ({ otherCreateDocument }) => otherCreateDocument, + lazyExisting: ({ existingDocuments }) => { + return () => existingDocuments[0] + }, + lazyOtherCreate: ({ migration, otherCreateDocument }) => { + return () => + migration.getByUID( + otherCreateDocument.document.type, + otherCreateDocument.document.uid!, + ) + }, + lazyOtherCreateMissingID: ({ migration, otherCreateDocument }) => { + return () => { + const doc = migration.getByUID( + otherCreateDocument.document.type, + otherCreateDocument.document.uid!, + ) + + delete doc?.document.id + + return doc + } + }, + richTextLinkNode: ({ existingDocuments }) => [ + { + type: RichTextNodeType.paragraph, + text: "lorem", + spans: [ + { type: RichTextNodeType.strong, start: 0, end: 5 }, + { + type: RichTextNodeType.hyperlink, + start: 0, + end: 5, + data: existingDocuments[0], + }, + ], + }, + ], +}) + +testMigrationFieldPatching( + "patches content relationship fields (from Prismic)", + { + simple: ({ ctx, otherFromPrismicDocument }) => { + const contentRelationship = ctx.mock.value.link({ + type: LinkType.Document, + }) + // `migrationDocuments` contains documents from "another repository" + contentRelationship.id = + otherFromPrismicDocument.originalPrismicDocument!.id + + return contentRelationship + }, + withText: ({ ctx, otherFromPrismicDocument }) => { + const contentRelationship = ctx.mock.value.link({ + type: LinkType.Document, + }) + // `migrationDocuments` contains documents from "another repository" + contentRelationship.id = + otherFromPrismicDocument.originalPrismicDocument!.id + + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + contentRelationship.text = "foo" + + return contentRelationship + }, + broken: () => { + return { + link_type: LinkType.Document, + id: "id", + type: "type", + tags: [], + lang: "lang", + isBroken: true, + } + }, + }, + { mode: "fromPrismic" }, +) diff --git a/test/writeClient-migrate-patch-image.test.ts b/test/writeClient-migrate-patch-image.test.ts new file mode 100644 index 00000000..9f3ce7ab --- /dev/null +++ b/test/writeClient-migrate-patch-image.test.ts @@ -0,0 +1,94 @@ +import { testMigrationFieldPatching } from "./__testutils__/testMigrationFieldPatching" + +import type { ImageField, MigrationImage } from "../src" + +testMigrationFieldPatching( + "patches image fields", + { + new: ({ migration }) => migration.createAsset("foo", "foo.png"), + newLongForm: ({ migration }) => { + return { + id: migration.createAsset("foo", "foo.png"), + } + }, + newThumbnails: ({ migration }) => { + return { + id: migration.createAsset("foo", "foo.png"), + square: migration.createAsset("foo", "foo.png"), + } + }, + existing: ({ existingAssets }) => { + const asset = existingAssets[0] + + return { + id: asset.id, + url: asset.url, + dimensions: { width: asset.width!, height: asset.height! }, + edit: { x: 0, y: 0, zoom: 1, background: "transparent" }, + alt: asset.alt || null, + copyright: asset.credits || null, + } + }, + }, +) + +testMigrationFieldPatching( + "patches image fields (from Prismic)", + { + simple: ({ ctx, mockedDomain }) => { + return { + ...ctx.mock.value.image({ state: "filled" }), + id: "foo-id", + url: `${mockedDomain}/foo.png`, + } + }, + withThumbnails: ({ ctx, mockedDomain }) => { + const image = ctx.mock.value.image({ state: "filled" }) + const id = "foo-id" + + return { + ...image, + id, + url: `${mockedDomain}/foo.png?some=query`, + square: { + ...image, + id, + url: `${mockedDomain}/foo.png?some=other&query=params`, + }, + } + }, + withThumbnailsNoAlt: ({ ctx, mockedDomain }) => { + const image = ctx.mock.value.image({ state: "filled" }) + image.alt = null + const id = "foo-id" + + return { + ...image, + id, + url: `${mockedDomain}/foo.png?some=query`, + square: { + ...image, + id, + url: `${mockedDomain}/foo.png?some=other&query=params`, + }, + } + }, + withSpecialTypeThumbnail: ({ ctx, mockedDomain }) => { + const image = ctx.mock.value.image({ state: "filled" }) + const id = "foo-id" + + return { + ...image, + id, + url: `${mockedDomain}/foo.png?some=query`, + type: { + ...image, + id, + url: `${mockedDomain}/foo.png?some=other&query=params`, + }, + } + }, + empty: ({ ctx }) => ctx.mock.value.image({ state: "empty" }), + }, + { mode: "fromPrismic" }, +) diff --git a/test/writeClient-migrate-patch-linkToMedia.test.ts b/test/writeClient-migrate-patch-linkToMedia.test.ts new file mode 100644 index 00000000..efdf0984 --- /dev/null +++ b/test/writeClient-migrate-patch-linkToMedia.test.ts @@ -0,0 +1,135 @@ +import { testMigrationFieldPatching } from "./__testutils__/testMigrationFieldPatching" + +import type { + InjectMigrationSpecificTypes, + LinkToMediaField, + RichTextField, +} from "../src" +import { LinkType, RichTextNodeType } from "../src" +import type { Asset } from "../src/types/api/asset/asset" +import type { MigrationLinkToMedia } from "../src/types/migration/Asset" + +const assetToLinkToMedia = ( + asset: Asset, + text?: string, +): LinkToMediaField<"filled"> => { + return { + id: asset.id, + link_type: LinkType.Media, + name: asset.filename, + kind: asset.kind, + url: asset.url, + size: `${asset.size}`, + height: typeof asset.height === "number" ? `${asset.height}` : undefined, + width: typeof asset.width === "number" ? `${asset.width}` : undefined, + // TODO: Remove when link text PR is merged + // @ts-expect-error - Future-proofing for link text + text, + } +} + +testMigrationFieldPatching< + | MigrationLinkToMedia + | LinkToMediaField + | InjectMigrationSpecificTypes +>("patches link to media fields", { + new: ({ migration }) => { + return { + link_type: LinkType.Media, + id: migration.createAsset("foo", "foo.png"), + } + }, + newWithText: ({ migration }) => { + return { + link_type: LinkType.Media, + id: migration.createAsset("foo", "foo.png"), + text: "foo", + } + }, + newNonImage: ({ migration }) => { + const migrationAsset = migration.createAsset("foo", "foo.pdf", { + tags: ["__pdf__"], + }) + + return { + link_type: LinkType.Media, + id: migrationAsset, + } + }, + existing: ({ existingAssets }) => assetToLinkToMedia(existingAssets[0]), + empty: ({ migration }) => { + const asset = migration.createAsset("foo", "foo.png") + asset.config.id = "empty" + + return { + link_type: LinkType.Media, + id: asset, + } + }, + richTextNew: ({ migration }) => [ + { + type: RichTextNodeType.paragraph, + text: "lorem", + spans: [ + { type: RichTextNodeType.strong, start: 0, end: 5 }, + { + type: RichTextNodeType.hyperlink, + start: 0, + end: 5, + data: { + link_type: LinkType.Media, + id: migration.createAsset("foo", "foo.png"), + }, + }, + ], + }, + ], + richTextExisting: ({ existingAssets }) => [ + { + type: RichTextNodeType.paragraph, + text: "lorem", + spans: [ + { type: RichTextNodeType.strong, start: 0, end: 5 }, + { + type: RichTextNodeType.hyperlink, + start: 0, + end: 5, + data: assetToLinkToMedia(existingAssets[0]), + }, + ], + }, + ], +}) + +testMigrationFieldPatching( + "patches link to media fields (from Prismic)", + { + simple: ({ ctx, mockedDomain }) => { + return { + ...ctx.mock.value.linkToMedia({ state: "filled" }), + id: "foo-id", + url: `${mockedDomain}/foo.png`, + } + }, + inRichText: ({ ctx, mockedDomain }) => [ + { + type: RichTextNodeType.paragraph, + text: "lorem", + spans: [ + { type: RichTextNodeType.strong, start: 0, end: 5 }, + { + type: RichTextNodeType.hyperlink, + start: 0, + end: 5, + data: { + ...ctx.mock.value.linkToMedia({ state: "filled" }), + id: "foo-id", + url: `${mockedDomain}/foo.png`, + }, + }, + ], + }, + ], + }, + { mode: "fromPrismic" }, +) diff --git a/test/writeClient-migrate-patch-rtImageNode.test.ts b/test/writeClient-migrate-patch-rtImageNode.test.ts new file mode 100644 index 00000000..a7685cc4 --- /dev/null +++ b/test/writeClient-migrate-patch-rtImageNode.test.ts @@ -0,0 +1,66 @@ +import { testMigrationFieldPatching } from "./__testutils__/testMigrationFieldPatching" + +import type { InjectMigrationSpecificTypes, RichTextField } from "../src" +import { RichTextNodeType } from "../src" + +testMigrationFieldPatching>( + "patches rich text image nodes", + { + new: ({ ctx, migration }) => [ + ...ctx.mock.value.richText({ pattern: "short", state: "filled" }), + { + type: RichTextNodeType.image, + id: migration.createAsset("foo", "foo.png"), + }, + ], + existing: ({ ctx, existingAssets }) => [ + ...ctx.mock.value.richText({ pattern: "short", state: "filled" }), + { + type: RichTextNodeType.image, + id: existingAssets[0].id, + url: existingAssets[0].url, + dimensions: { + width: existingAssets[0].width!, + height: existingAssets[0].height!, + }, + edit: { x: 0, y: 0, zoom: 1, background: "transparent" }, + alt: existingAssets[0].alt || null, + copyright: existingAssets[0].credits || null, + }, + ], + newLinkTo: ({ ctx, migration, existingDocuments }) => [ + ...ctx.mock.value.richText({ pattern: "short", state: "filled" }), + { + type: RichTextNodeType.image, + id: migration.createAsset("foo", "foo.png"), + linkTo: existingDocuments[0], + }, + ], + }, +) + +testMigrationFieldPatching>( + "patches rich text image nodes (from Prismic)", + { + simple: ({ ctx, mockedDomain }) => [ + ...ctx.mock.value.richText({ pattern: "short", state: "filled" }), + { + type: RichTextNodeType.image, + ...ctx.mock.value.image({ state: "filled" }), + id: "foo-id", + url: `${mockedDomain}/foo.png`, + }, + ], + withLinkTo: ({ ctx, mockedDomain, existingDocuments }) => [ + ...ctx.mock.value.richText({ pattern: "short", state: "filled" }), + { + type: RichTextNodeType.image, + ...ctx.mock.value.image({ state: "filled" }), + id: "foo-id", + url: `${mockedDomain}/foo.png`, + linkTo: existingDocuments[0], + }, + ], + }, + { mode: "fromPrismic" }, +) diff --git a/test/writeClient-migrate-patch-simpleField.test.ts b/test/writeClient-migrate-patch-simpleField.test.ts new file mode 100644 index 00000000..0900323b --- /dev/null +++ b/test/writeClient-migrate-patch-simpleField.test.ts @@ -0,0 +1,40 @@ +import { testMigrationFieldPatching } from "./__testutils__/testMigrationFieldPatching" + +import type { AnyRegularField, GroupField, RichTextField } from "../src" +import { RichTextNodeType } from "../src" + +testMigrationFieldPatching( + "does not patch simple fields", + { + embed: ({ ctx }) => ctx.mock.value.embed({ state: "filled" }), + date: ({ ctx }) => ctx.mock.value.date({ state: "filled" }), + timestamp: ({ ctx }) => ctx.mock.value.timestamp({ state: "filled" }), + color: ({ ctx }) => ctx.mock.value.color({ state: "filled" }), + number: ({ ctx }) => ctx.mock.value.number({ state: "filled" }), + keyText: ({ ctx }) => ctx.mock.value.keyText({ state: "filled" }), + richTextSimple: ({ ctx }) => + ctx.mock.value + .richText({ state: "filled", pattern: "long" }) + .filter( + (node) => node.type !== RichTextNodeType.image, + ) as RichTextField, + select: ({ ctx }) => + ctx.mock.value.select({ + model: ctx.mock.model.select({ options: ["foo", "bar"] }), + state: "filled", + }), + boolean: ({ ctx }) => ctx.mock.value.boolean(), + geoPoint: ({ ctx }) => ctx.mock.value.geoPoint({ state: "filled" }), + integration: ({ ctx }) => ctx.mock.value.integration({ state: "filled" }), + linkToWeb: ({ ctx }) => + ctx.mock.value.link({ type: "Web", withTargetBlank: true }), + richTextMisleadingGroup: () => [{ type: "paragraph" }], + documentMisleadingObject: () => { + return { + id: "foo", + href: "bar", + } + }, + }, + { expectStrictEqual: true }, +) diff --git a/test/writeClient-migrate.test.ts b/test/writeClient-migrate.test.ts new file mode 100644 index 00000000..660aafdf --- /dev/null +++ b/test/writeClient-migrate.test.ts @@ -0,0 +1,157 @@ +import { it as _it, expect, vi } from "vitest" + +import { createTestWriteClient } from "./__testutils__/createWriteClient" +import { + mockAsset, + mockPrismicAssetAPI, +} from "./__testutils__/mockPrismicAssetAPI" +import { mockPrismicMigrationAPI } from "./__testutils__/mockPrismicMigrationAPI" +import { mockPrismicRestAPIV2 } from "./__testutils__/mockPrismicRestAPIV2" + +import * as prismic from "../src" + +// Skip test on Node 16 and 18 (File and FormData support) +const isNode16 = process.version.startsWith("v16") +const isNode18 = process.version.startsWith("v18") +const it = _it.skipIf(isNode16 || isNode18) + +it.concurrent("performs migration", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const asset = mockAsset(ctx) + const newDocuments = [{ id: "foo" }, { id: "bar" }] + + mockPrismicRestAPIV2({ ctx }) + const { assetsDatabase } = mockPrismicAssetAPI({ + ctx, + client, + newAssets: [asset], + }) + const { documentsDatabase } = mockPrismicMigrationAPI({ + ctx, + client, + newDocuments, + }) + + const migration = prismic.createMigration() + + const { id: _id, ...documentFoo } = + ctx.mock.value.document() as prismic.ExistingPrismicDocument + documentFoo.data = { + image: migration.createAsset("foo", "foo.png"), + link: () => migration.getByUID("bar", "bar"), + } + + const { id: __id, ...documentBar } = ctx.mock.value.document() + documentBar.type = "bar" + documentBar.uid = "bar" + + migration.createDocument(documentFoo, "foo") + migration.createDocument(documentBar, "bar") + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + expect(migration._assets?.size).toBe(1) + expect(assetsDatabase.flat()).toHaveLength(1) + expect(migration._documents.length).toBe(2) + expect(Object.keys(documentsDatabase)).toHaveLength(2) + + expect(reporter).toHaveBeenCalledWith({ + type: "start", + data: { + pending: { + documents: 2, + assets: 1, + }, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:created", + data: { + created: 1, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "documents:created", + data: { + created: 2, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "documents:updated", + data: { + updated: 2, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "end", + data: { + migrated: { + documents: 2, + assets: 1, + }, + }, + }) +}) + +it.concurrent("migrates nothing when migration is empty", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicRestAPIV2({ ctx }) + mockPrismicAssetAPI({ ctx, client }) + // Not mocking Migration API to test we're not calling it + // mockPrismicMigrationAPI({ ctx, client }) + + const migration = prismic.createMigration() + + const reporter = vi.fn() + + await client.migrate(migration, { reporter }) + + expect(reporter).toHaveBeenCalledWith({ + type: "start", + data: { + pending: { + documents: 0, + assets: 0, + }, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "assets:created", + data: { + created: 0, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "documents:created", + data: { + created: 0, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "documents:updated", + data: { + updated: 0, + }, + }) + + expect(reporter).toHaveBeenCalledWith({ + type: "end", + data: { + migrated: { + documents: 0, + assets: 0, + }, + }, + }) +}) diff --git a/test/writeClient-updateDocument.test.ts b/test/writeClient-updateDocument.test.ts new file mode 100644 index 00000000..6e6ee357 --- /dev/null +++ b/test/writeClient-updateDocument.test.ts @@ -0,0 +1,167 @@ +import { expect, it } from "vitest" + +import { createTestWriteClient } from "./__testutils__/createWriteClient" +import { mockPrismicMigrationAPI } from "./__testutils__/mockPrismicMigrationAPI" + +import { ForbiddenError, NotFoundError } from "../src" +import { UNKNOWN_RATE_LIMIT_DELAY } from "../src/BaseClient" + +it.concurrent("updates a document", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { documentsDatabase } = mockPrismicMigrationAPI({ + ctx, + client, + existingDocuments: 1, + }) + + const document = Object.values(documentsDatabase)[0] + + await expect( + // @ts-expect-error - testing purposes + client.updateDocument(document.id, { + uid: "uid", + data: {}, + }), + ).resolves.toBeUndefined() +}) + +it.concurrent("throws not found error on not found ID", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicMigrationAPI({ ctx, client }) + + await expect(() => + // @ts-expect-error - testing purposes + client.updateDocument("foo", { + uid: "uid", + data: {}, + }), + ).rejects.toThrow(NotFoundError) +}) + +it.concurrent("respects unknown rate limit", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const { documentsDatabase } = mockPrismicMigrationAPI({ + ctx, + client, + existingDocuments: 1, + }) + + const document = Object.values(documentsDatabase)[0] + + const args = [ + document.id, + { + uid: "uid", + data: {}, + }, + ] + + const start = Date.now() + + // @ts-expect-error - testing purposes + await client.updateDocument(...args) + + expect(Date.now() - start).toBeLessThan(UNKNOWN_RATE_LIMIT_DELAY) + + // @ts-expect-error - testing purposes + await client.updateDocument(...args) + + expect(Date.now() - start).toBeGreaterThanOrEqual(UNKNOWN_RATE_LIMIT_DELAY) +}) + +it.concurrent("throws forbidden error on invalid credentials", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + mockPrismicMigrationAPI({ ctx, client, writeToken: "invalid" }) + + await expect(() => + // @ts-expect-error - testing purposes + client.updateDocument("foo", { + uid: "uid", + data: {}, + }), + ).rejects.toThrow(ForbiddenError) +}) + +// It seems like p-limit and node-fetch are not happy friends :/ +// https://github.com/sindresorhus/p-limit/issues/64 +it.skip("is abortable with an AbortController", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const controller = new AbortController() + controller.abort() + + const { documentsDatabase } = mockPrismicMigrationAPI({ + ctx, + client, + existingDocuments: 1, + }) + + const document = Object.values(documentsDatabase)[0] + + await expect(() => + // @ts-expect-error - testing purposes + client.updateDocument( + document.id, + { + ...document, + uid: "uid", + data: {}, + }, + { fetchOptions: { signal: controller.signal } }, + ), + ).rejects.toThrow(/aborted/i) +}) + +it.concurrent("supports custom headers", async (ctx) => { + const client = createTestWriteClient({ ctx }) + + const headers = { "x-custom": "foo" } + + const { documentsDatabase } = mockPrismicMigrationAPI({ + ctx, + client, + requiredHeaders: headers, + existingDocuments: 1, + }) + + const document = Object.values(documentsDatabase)[0] + + await ctx + .expect( + // @ts-expect-error - testing purposes + client.updateDocument( + document.id, + { + ...document, + uid: "uid", + data: {}, + }, + { fetchOptions: { headers } }, + ), + ) + .resolves.toBeUndefined() + ctx.expect.assertions(2) +}) + +it("throws fetch errors as-is", async (ctx) => { + const client = createTestWriteClient({ + ctx, + clientConfig: { + fetch: () => { + throw new Error(ctx.task.name) + }, + }, + }) + + await expect(() => + // @ts-expect-error - testing purposes + client.updateDocument("foo", { + uid: "uid", + data: {}, + }), + ).rejects.toThrowError(ctx.task.name) +}) diff --git a/test/writeClient.test.ts b/test/writeClient.test.ts new file mode 100644 index 00000000..966d96a6 --- /dev/null +++ b/test/writeClient.test.ts @@ -0,0 +1,63 @@ +import { expect, it, vi } from "vitest" + +import * as prismic from "../src" + +it("creates a write client with `createWriteClient`", () => { + const client = prismic.createWriteClient("qwerty", { + fetch: vi.fn(), + writeToken: "xxx", + }) + + expect(client).toBeInstanceOf(prismic.WriteClient) +}) + +it("warns in constructor if running in a browser-like environment", () => { + const originalWindow = globalThis.window + globalThis.window = {} as Window & typeof globalThis + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0) + + prismic.createWriteClient("qwerty", { + fetch: vi.fn(), + writeToken: "xxx", + }) + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching(/avoid-write-client-in-browser/i), + ) + + consoleWarnSpy.mockRestore() + + globalThis.window = originalWindow +}) + +it("uses provided Asset API endpoint and adds `/` suffix", () => { + const client = prismic.createWriteClient("qwerty", { + fetch: vi.fn(), + assetAPIEndpoint: "https://example.com", + writeToken: "xxx", + }) + + expect(client.assetAPIEndpoint).toBe("https://example.com/") +}) + +it("uses provided Migration API endpoint and adds `/` suffix", () => { + const client = prismic.createWriteClient("qwerty", { + fetch: vi.fn(), + migrationAPIEndpoint: "https://example.com", + writeToken: "xxx", + }) + + expect(client.migrationAPIEndpoint).toBe("https://example.com/") +}) + +it("uses provided Migration API key", () => { + const client = prismic.createWriteClient("qwerty", { + fetch: vi.fn(), + writeToken: "xxx", + migrationAPIKey: "yyy", + }) + + expect(client.migrationAPIKey).toBe("yyy") +})