UNPKG

next-drupal

Version:
1,580 lines (1,572 loc) 77.7 kB
import { DRAFT_DATA_COOKIE_NAME, DRAFT_MODE_COOKIE_NAME } from "./chunk-2RPT7DWB.js"; import { buildHeaders, buildUrl, deserialize, getAccessToken, getJsonApiIndex, getJsonApiPathForResourceType, getMenu, getPathFromContext, syncDrupalPreviewRoutes } from "./chunk-EWSX567N.js"; // src/jsonapi-errors.ts var JsonApiErrors = class _JsonApiErrors extends Error { constructor(errors, statusCode, messagePrefix = "") { super(); this.errors = errors; this.statusCode = statusCode; this.message = (messagePrefix ? `${messagePrefix} ` : "") + _JsonApiErrors.formatMessage(errors); } static formatMessage(errors) { if (typeof errors === "string") { return errors; } const [error] = errors; let message = `${error.status} ${error.title}`; if (error.detail) { message += ` ${error.detail}`; } return message; } }; // src/next-drupal-base.ts import { stringify } from "qs"; // src/logger.ts var LOG_MESSAGE_PREFIX = "[next-drupal][log]:"; var DEBUG_MESSAGE_PREFIX = "[next-drupal][debug]:"; var WARN_MESSAGE_PREFIX = "[next-drupal][warn]:"; var ERROR_MESSAGE_PREFIX = "[next-drupal][error]:"; var logger = { log(message) { console.log(LOG_MESSAGE_PREFIX, message); }, debug(message) { console.debug(DEBUG_MESSAGE_PREFIX, message); }, warn(message) { console.warn(WARN_MESSAGE_PREFIX, message); }, error(message) { console.error(ERROR_MESSAGE_PREFIX, message); } }; // src/next-drupal-base.ts var DEFAULT_API_PREFIX = ""; var DEFAULT_FRONT_PAGE = "/home"; var DEFAULT_WITH_AUTH = false; var DEFAULT_AUTH_URL = "/oauth/token"; var DEFAULT_HEADERS = { "Content-Type": "application/json", Accept: "application/json" }; var NextDrupalBase = class { /** * Instantiates a new NextDrupalBase. * * const client = new NextDrupalBase(baseUrl) * * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix. * @param {options} options Options for NextDrupalBase. */ constructor(baseUrl, options = {}) { if (!baseUrl || typeof baseUrl !== "string") { throw new Error("The 'baseUrl' param is required."); } const { accessToken, apiPrefix = DEFAULT_API_PREFIX, auth, debug = false, fetcher, frontPage = DEFAULT_FRONT_PAGE, headers = DEFAULT_HEADERS, logger: logger2 = logger, withAuth = DEFAULT_WITH_AUTH } = options; this.accessToken = accessToken; this.apiPrefix = apiPrefix; this.auth = auth; this.baseUrl = baseUrl; this.fetcher = fetcher; this.frontPage = frontPage; this.isDebugEnabled = !!debug; this.headers = headers; this.logger = logger2; this.withAuth = withAuth; this.debug("Debug mode is on."); } set apiPrefix(apiPrefix) { this._apiPrefix = apiPrefix === "" || apiPrefix.startsWith("/") ? apiPrefix : `/${apiPrefix}`; } get apiPrefix() { return this._apiPrefix; } set auth(auth) { if (typeof auth === "object") { const checkUsernamePassword = auth; const checkAccessToken = auth; const checkClientIdSecret = auth; if (checkUsernamePassword.username !== void 0 || checkUsernamePassword.password !== void 0) { if (!checkUsernamePassword.username || !checkUsernamePassword.password) { throw new Error( "'username' and 'password' are required for auth. See https://next-drupal.org/docs/client/auth" ); } } else if (checkAccessToken.access_token !== void 0 || checkAccessToken.token_type !== void 0) { if (!checkAccessToken.access_token || !checkAccessToken.token_type) { throw new Error( "'access_token' and 'token_type' are required for auth. See https://next-drupal.org/docs/client/auth" ); } } else if (!checkClientIdSecret.clientId || !checkClientIdSecret.clientSecret) { throw new Error( "'clientId' and 'clientSecret' are required for auth. See https://next-drupal.org/docs/client/auth" ); } this._auth = { ...isClientIdSecretAuth(auth) ? { url: DEFAULT_AUTH_URL } : {}, ...auth }; } else { this._auth = auth; } } get auth() { return this._auth; } set headers(headers) { this._headers = new Headers(headers); } get headers() { return this._headers; } set token(token) { this._token = token; this._tokenExpiresOn = Date.now() + token.expires_in * 1e3; } get token() { return this._token; } /** * Fetches a resource from the given input URL or path. * * @param {RequestInfo} input The url to fetch from. * @param {FetchOptions} init The fetch options with `withAuth`. * If `withAuth` is set, `fetch` will fetch an `Authorization` header before making the request. * @returns {Promise<Response>} The fetch response. * @remarks * To provide your own custom fetcher, see the fetcher docs. * @example * ```ts * const url = drupal.buildUrl("/jsonapi/node/article", { * sort: "-created", * "fields[node--article]": "title,path", * }) * * const response = await drupal.fetch(url.toString()) * ``` */ async fetch(input, { withAuth, ...init } = {}) { init.credentials = "include"; const headers = new Headers(this.headers); if (init?.headers) { const initHeaders = new Headers(init?.headers); for (const key of initHeaders.keys()) { headers.set(key, initHeaders.get(key)); } } if (withAuth) { headers.set( "Authorization", await this.getAuthorizationHeader( withAuth === true ? this.auth : withAuth ) ); } init.headers = headers; if (typeof input === "string" && input.startsWith("/")) { input = `${this.baseUrl}${input}`; } if (this.fetcher) { this.debug(`Using custom fetcher, fetching: ${input}`); return await this.fetcher(input, init); } this.debug(`Using default fetch, fetching: ${input}`); return await fetch(input, init); } /** * Gets the authorization header value based on the provided auth configuration. * * @param {NextDrupalAuth} auth The auth configuration. * @returns {Promise<string>} The authorization header value. */ async getAuthorizationHeader(auth) { let header; if (isBasicAuth(auth)) { const basic = Buffer.from(`${auth.username}:${auth.password}`).toString( "base64" ); header = `Basic ${basic}`; this.debug("Using basic authorization header."); } else if (isClientIdSecretAuth(auth)) { const token = await this.getAccessToken(auth); header = `Bearer ${token.access_token}`; this.debug( "Using access token authorization header retrieved from Client Id/Secret." ); } else if (isAccessTokenAuth(auth)) { header = `${auth.token_type} ${auth.access_token}`; this.debug("Using access token authorization header."); } else if (typeof auth === "string") { header = auth; this.debug("Using custom authorization header."); } else if (typeof auth === "function") { header = auth(); this.debug("Using custom authorization callback."); } else { throw new Error( "auth is not configured. See https://next-drupal.org/docs/client/auth" ); } return header; } /** * Builds a URL with the given path and search parameters. * * @param {string} path The path for the url. Example: "/example" * @param {string | Record<string, string> | URLSearchParams | JsonApiParams} searchParams Optional query parameters. * @returns {URL} The constructed URL. * @example * ```ts * const drupal = new DrupalClient("https://example.com") * * // https://drupal.org * drupal.buildUrl("https://drupal.org").toString() * * // https://example.com/foo * drupal.buildUrl("/foo").toString() * * // https://example.com/foo?bar=baz * client.buildUrl("/foo", { bar: "baz" }).toString() * ``` * * Build a URL from `DrupalJsonApiParams` * ```ts * const params = { * getQueryObject: () => ({ * sort: "-created", * "fields[node--article]": "title,path", * }), * } * * // https://example.com/jsonapi/node/article?sort=-created&fields%5Bnode--article%5D=title%2Cpath * drupal.buildUrl("/jsonapi/node/article", params).toString() * ``` */ buildUrl(path, searchParams) { const url = new URL(path, this.baseUrl); const search = ( // Handle DrupalJsonApiParams objects. searchParams && typeof searchParams === "object" && "getQueryObject" in searchParams ? searchParams.getQueryObject() : searchParams ); if (search) { url.search = stringify(search); } return url; } /** * Builds an endpoint URL with the given options. * * @param {Object} options The options for building the endpoint. * @param {string} options.locale The locale. * @param {string} options.path The path. * @param {EndpointSearchParams} options.searchParams The search parameters. * @returns {Promise<string>} The constructed endpoint URL. */ async buildEndpoint({ locale = "", path = "", searchParams } = {}) { const localeSegment = locale ? `/${locale}` : ""; if (path && !path.startsWith("/")) { path = `/${path}`; } return this.buildUrl( `${localeSegment}${this.apiPrefix}${path}`, searchParams ).toString(); } /** * Constructs a path from the given segment and options. * * @param {string | string[]} segment The path segment. * @param {Object} options The options for constructing the path. * @param {Locale} options.locale The locale. * @param {Locale} options.defaultLocale The default locale. * @param {PathPrefix} options.pathPrefix The path prefix. * @returns {string} The constructed path. */ constructPathFromSegment(segment, options = {}) { let { pathPrefix = "" } = options; const { locale, defaultLocale } = options; if (pathPrefix) { if (!pathPrefix?.startsWith("/")) { pathPrefix = `/${options.pathPrefix}`; } if (pathPrefix.endsWith("/")) { pathPrefix = pathPrefix.slice(0, -1); } } if (!Array.isArray(segment)) { segment = segment ? [segment] : []; } segment = segment.map((part) => encodeURIComponent(part)).join("/"); if (!segment && !pathPrefix) { segment = this.frontPage; } if (segment && !segment.startsWith("/")) { segment = `/${segment}`; } if (segment.endsWith("/")) { segment = segment.slice(0, -1); } return this.addLocalePrefix(`${pathPrefix}${segment}`, { locale, defaultLocale }); } /** * Adds a locale prefix to the given path. * * @param {string} path The path. * @param {Object} options The options for adding the locale prefix. * @param {Locale} options.locale The locale. * @param {Locale} options.defaultLocale The default locale. * @returns {string} The path with the locale prefix. */ addLocalePrefix(path, options = {}) { const { locale, defaultLocale } = options; if (!path.startsWith("/")) { path = `/${path}`; } let localePrefix = ""; if (locale && !path.startsWith(`/${locale}`) && locale !== defaultLocale) { localePrefix = `/${locale}`; } return `${localePrefix}${path}`; } /** * Retrieve an access token. * * @param {NextDrupalAuthClientIdSecret} clientIdSecret The client ID and secret. * @returns {Promise<AccessToken>} The access token. * @remarks * If options is not provided, `DrupalClient` will use the `clientId` and `clientSecret` configured in `auth`. * @example * ```ts * const accessToken = await drupal.getAccessToken({ * clientId: "7034f4db-7151-466f-a711-8384bddb9e60", * clientSecret: "d92Fm^ds", * }) * ``` */ async getAccessToken(clientIdSecret) { if (this.accessToken) { return this.accessToken; } let auth; if (isClientIdSecretAuth(clientIdSecret)) { auth = { url: DEFAULT_AUTH_URL, ...clientIdSecret }; } else if (isClientIdSecretAuth(this.auth)) { auth = { ...this.auth }; } else if (typeof this.auth === "undefined") { throw new Error( "auth is not configured. See https://next-drupal.org/docs/client/auth" ); } else { throw new Error( `'clientId' and 'clientSecret' required. See https://next-drupal.org/docs/client/auth` ); } const url = this.buildUrl(auth.url); if (this.token && Date.now() < this._tokenExpiresOn && this._tokenRequestDetails?.clientId === auth?.clientId && this._tokenRequestDetails?.clientSecret === auth?.clientSecret && this._tokenRequestDetails?.scope === auth?.scope) { this.debug(`Using existing access token.`); return this.token; } this.debug(`Fetching new access token.`); const clientCredentials = { username: auth.clientId, password: auth.clientSecret }; const body = new URLSearchParams({ grant_type: "client_credentials" }); if (auth?.scope) { body.set("scope", auth.scope); this.debug(`Using scope: ${auth.scope}`); } const response = await this.fetch(url.toString(), { method: "POST", headers: { Authorization: await this.getAuthorizationHeader(clientCredentials), Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" }, body }); await this.throwIfJsonErrors( response, "Error while fetching new access token: " ); const result = await response.json(); this.token = result; this._tokenRequestDetails = auth; return result; } /** * Validates the draft URL using the provided search parameters. * * @param {URLSearchParams} searchParams The search parameters. * @returns {Promise<Response>} The validation response. */ async validateDraftUrl(searchParams) { const path = searchParams.get("path"); this.debug(`Fetching draft url validation for ${path}.`); let response; try { const validateUrl = this.buildUrl("/next/draft-url").toString(); response = await this.fetch(validateUrl, { method: "POST", headers: { Accept: "application/vnd.api+json", "Content-Type": "application/json" }, body: JSON.stringify(Object.fromEntries(searchParams.entries())) }); } catch (error) { response = new Response(JSON.stringify({ message: error.message }), { status: 401 }); } this.debug( response.status !== 200 ? `Could not validate path, ${path}` : `Validated path, ${path}` ); return response; } /** * Logs a debug message if debug mode is enabled. * * @param {string} message The debug message. */ debug(message) { this.isDebugEnabled && this.logger.debug(message); } /** * Throws an error if the response contains JSON:API errors. * * @param {Response} response The fetch response. * @param {string} messagePrefix The error message prefix. * @throws {JsonApiErrors} The JSON:API errors. */ async throwIfJsonErrors(response, messagePrefix = "") { if (!response?.ok) { const errors = await this.getErrorsFromResponse(response); throw new JsonApiErrors(errors, response.status, messagePrefix); } } /** * Extracts errors from the fetch response. * * @param {Response} response The fetch response. * @returns {Promise<string | JsonApiResponse>} The extracted errors. */ async getErrorsFromResponse(response) { const type = response.headers.get("content-type"); let error; if (type === "application/json") { error = await response.json(); if (error?.message) { return error.message; } } else if (type === "application/vnd.api+json") { error = await response.json(); if (error?.errors?.length) { return error.errors; } } return response.statusText; } }; function isBasicAuth(auth) { return auth?.username !== void 0 && auth?.password !== void 0; } function isAccessTokenAuth(auth) { return auth?.access_token !== void 0 && auth?.token_type !== void 0; } function isClientIdSecretAuth(auth) { return auth?.clientId !== void 0 && auth?.clientSecret !== void 0; } // src/next-drupal.ts import { Jsona } from "jsona"; import { stringify as stringify2 } from "qs"; // src/menu-tree.ts var DrupalMenuTree = class _DrupalMenuTree extends Array { constructor(menuItems, parentId = "", depth = 1) { super(); this.parentId = parentId; this.depth = depth; if (menuItems?.length) { this.build(menuItems, parentId); } } build(menuItems, parentId) { const children = menuItems.filter( (menuItem) => menuItem?.parent === parentId ); for (const menuItem of children) { const subtree = new _DrupalMenuTree( menuItems, menuItem.id, this.depth + 1 ); this.push({ ...menuItem, items: subtree.length ? subtree : void 0 }); } } }; // src/next-drupal.ts var DEFAULT_API_PREFIX2 = "/jsonapi"; var DEFAULT_HEADERS2 = { "Content-Type": "application/vnd.api+json", Accept: "application/vnd.api+json" }; function useJsonaDeserialize() { const jsonFormatter = new Jsona(); return function jsonaDeserialize(body, options) { return jsonFormatter.deserialize(body, options); }; } var NextDrupal = class extends NextDrupalBase { /** * Instantiates a new NextDrupal. * * const client = new NextDrupal(baseUrl) * * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix. * @param {options} options Options for NextDrupal. */ constructor(baseUrl, options = {}) { super(baseUrl, options); const { apiPrefix = DEFAULT_API_PREFIX2, cache = null, deserializer, headers = DEFAULT_HEADERS2, throwJsonApiErrors = true, useDefaultEndpoints = true } = options; this.apiPrefix = apiPrefix; this.cache = cache; this.deserializer = deserializer ?? useJsonaDeserialize(); this.headers = headers; this.throwJsonApiErrors = !!throwJsonApiErrors; this.useDefaultEndpoints = !!useDefaultEndpoints; if (process.env.NODE_ENV === "production") { this.throwJsonApiErrors = false; } } /** * Creates a new resource of the specified type. * * @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`. * @param {JsonApiCreateResourceBody} body The body payload with data. * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<T>} The created resource. * @example * Create a node--page resource * ``` * const page = await drupal.createResource("node--page", { * data: { * attributes: { * title: "Page Title", * body: { * value: "<p>Content of body field</p>", * format: "full_html", * }, * }, * }, * }) * ``` * Create a node--article with a taxonomy term * ``` * const article = await drupal.createResource("node--article", { * data: { * attributes: { * title: "Title of Article", * body: { * value: "<p>Content of body field</p>", * format: "full_html", * }, * }, * relationships: { * field_category: { * data: { * type: "taxonomy_term--category", * id: "28ab9f26-927d-4e33-9510-b59a7ccdafe6", * }, * }, * }, * }, * }) * ``` * Using filters * ``` * const page = await drupal.createResource( * "node--page", * { * data: { * attributes: { * title: "Page Title", * body: { * value: "<p>Content of body field</p>", * format: "full_html", * }, * }, * }, * }, * { * params: { * "fields[node--page]": "title,path", * }, * } * ) * ``` * Using TypeScript with DrupalNode * ``` * import { DrupalNode } from "next-drupal" * const page = await drupal.createResource<DrupalNode>("node--page", { * data: { * attributes: { * title: "Page Title", * body: { * value: "<p>Content of body field</p>", * format: "full_html", * }, * }, * }, * }) * ``` */ async createResource(type, body, options) { options = { deserialize: true, withAuth: true, ...options }; const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? ( /* c8 ignore next */ options.locale ) : void 0, resourceType: type, searchParams: options?.params }); this.debug(`Creating resource of type ${type}.`); body.data.type = type; const response = await this.fetch(endpoint, { method: "POST", body: JSON.stringify(body), withAuth: options.withAuth, cache: options.cache }); await this.throwIfJsonErrors(response, "Error while creating resource: "); const json = await response.json(); return options.deserialize ? this.deserialize(json) : ( /* c8 ignore next */ json ); } /** * Creates a new file resource for the specified media type. * * @param {string} type The type of the resource. In most cases this is `file--file`. * @param {JsonApiCreateFileResourceBody} body The body payload with data. * - type: The resource type of the host entity. Example: `media--image` * - field: The name of the file field on the host entity. Example: `field_media_image` * - filename: The name of the file with extension. Example: `avatar.jpg` * - file: The file as a Buffer * @param {JsonApiOptions} options Options for the request. * @returns {Promise<T>} The created file resource. * @example * Create a file resource for a media--image entity * ```ts * const file = await drupal.createFileResource("file--file", { * data: { * attributes: { * type: "media--image", // The type of the parent resource * field: "field_media_image", // The name of the field on the parent resource * filename: "filename.jpg", * file: await fs.readFile("/path/to/file.jpg"), * }, * }, * }) * ``` * * You can then use this to create a new media--image with a relationship to the file: * ```ts * const media = await drupal.createResource<DrupalMedia>("media--image", { * data: { * attributes: { * name: "Name for the media", * }, * relationships: { * field_media_image: { * data: { * type: "file--file", * id: file.id, * }, * }, * }, * }, * }) * ``` */ async createFileResource(type, body, options) { options = { deserialize: true, withAuth: true, ...options }; const resourceType = body?.data?.attributes?.type; const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : void 0, resourceType, path: `/${body.data.attributes.field}`, searchParams: options?.params }); this.debug(`Creating file resource for media of type ${type}.`); const response = await this.fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/octet-stream", Accept: "application/vnd.api+json", "Content-Disposition": `file; filename="${body.data.attributes.filename}"` }, body: body.data.attributes.file, withAuth: options.withAuth, cache: options.cache }); await this.throwIfJsonErrors( response, "Error while creating file resource: " ); const json = await response.json(); return options.deserialize ? this.deserialize(json) : json; } /** * Updates an existing resource of the specified type. * * @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`. * @param {string} uuid The resource id. Example: `a50ffee7-ba94-46c9-9705-f9f8f440db94`. * @param {JsonApiUpdateResourceBody} body The body payload with data. * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<T>} The updated resource. * @example * Update a node--page resource * ```ts * const page = await drupal.updateResource( * "node--page", * "a50ffee7-ba94-46c9-9705-f9f8f440db94", * { * data: { * attributes: { * title: "Updated Title", * }, * }, * } * ) * ``` * * Using TypeScript with DrupalNode for a node entity type * ```ts * import { DrupalNode } from "next-drupal" * * const page = await drupal.updateResource<DrupalNode>( * "node--page", * "a50ffee7-ba94-46c9-9705-f9f8f440db94", * { * data: { * attributes: { * title: "Updated Title", * }, * }, * } * ) * ``` */ async updateResource(type, uuid, body, options) { options = { deserialize: true, withAuth: true, ...options }; const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? ( /* c8 ignore next */ options.locale ) : void 0, resourceType: type, path: `/${uuid}`, searchParams: options?.params }); this.debug(`Updating resource of type ${type} with id ${uuid}.`); body.data.type = type; body.data.id = uuid; const response = await this.fetch(endpoint, { method: "PATCH", body: JSON.stringify(body), withAuth: options.withAuth, cache: options.cache }); await this.throwIfJsonErrors(response, "Error while updating resource: "); const json = await response.json(); return options.deserialize ? this.deserialize(json) : ( /* c8 ignore next */ json ); } /** * Deletes an existing resource of the specified type. * * @param {string} type The type of the resource. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`. * @param {string} uuid The resource id. Example: `a50ffee7-ba94-46c9-9705-f9f8f440db94`. * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<boolean>} True if the resource was deleted, false otherwise. * @example * Delete a node--page resource * ```ts * const isDeleted = await drupal.deleteResource( * "node--page", * "a50ffee7-ba94-46c9-9705-f9f8f440db94" * ) * ``` */ async deleteResource(type, uuid, options) { options = { withAuth: true, params: {}, ...options }; const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? ( /* c8 ignore next */ options.locale ) : void 0, resourceType: type, path: `/${uuid}`, searchParams: options?.params }); this.debug(`Deleting resource of type ${type} with id ${uuid}.`); const response = await this.fetch(endpoint, { method: "DELETE", withAuth: options.withAuth, cache: options.cache }); await this.throwIfJsonErrors(response, "Error while deleting resource: "); return response.status === 204; } /** * Fetches a resource of the specified type by its UUID. * * @param {string} type The resource type. Example: `node--article`, `taxonomy_term--tags`, or `block_content--basic`. * @param {string} uuid The id of the resource. Example: `15486935-24bf-4be7-b858-a5b2de78d09d`. * @param {JsonApiOptions & JsonApiWithCacheOptions & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<T>} The fetched resource. * @examples * Get a page by uuid. * ```ts * const node = await drupal.getResource( * "node--page", * "07464e9f-9221-4a4f-b7f2-01389408e6c8" * ) * ``` * Get the es translation for a page by uuid. * ```ts * const node = await drupal.getResource( * "node--page", * "07464e9f-9221-4a4f-b7f2-01389408e6c8", * { * locale: "es", * defaultLocale: "en", * } * ) * ``` * Get the raw JSON:API response. * ```ts * const { data, meta, links } = await drupal.getResource( * "node--page", * "07464e9f-9221-4a4f-b7f2-01389408e6c8", * { * deserialize: false, * } * ) * ``` * Get a node--article resource using cache. * ```ts * const id = "07464e9f-9221-4a4f-b7f2-01389408e6c8" * * const article = await drupal.getResource("node--article", id, { * withCache: true, * cacheKey: `node--article:${id}`, * }) * ``` * Get a page resource with time-based revalidation. * ```ts * const node = await drupal.getResource( * "node--page", * "07464e9f-9221-4a4f-b7f2-01389408e6c8", * { next: { revalidate: 3600 } } * ) * ``` * Get a page resource with tag-based revalidation. * ```ts * const {slug} = params; * const path = drupal.translatePath(slug) * * const type = path.jsonapi.resourceName * const tag = `${path.entity.type}:${path.entity.id}` * * const node = await drupal.getResource(path, path.entity.uuid, { * params: params.getQueryObject(), * tags: [tag] * }) * ``` * Using DrupalNode for a node entity type. * ```ts * import { DrupalNode } from "next-drupal" * * const node = await drupal.getResource<DrupalNode>( * "node--page", * "07464e9f-9221-4a4f-b7f2-01389408e6c8" * ) * ``` * Using DrupalTaxonomyTerm for a taxonomy term entity type. * ```ts * import { DrupalTaxonomyTerm } from "next-drupal" * * const term = await drupal.getResource<DrupalTaxonomyTerm>( * "taxonomy_term--tags", * "7b47d7cc-9b1b-4867-a909-75dc1d61dfd3" * ) * ``` */ async getResource(type, uuid, options) { options = { deserialize: true, withAuth: this.withAuth, withCache: false, params: {}, ...options }; if (options.withCache) { const cached = await this.cache.get(options.cacheKey); if (cached) { this.debug(`Returning cached resource ${type} with id ${uuid}.`); const json2 = JSON.parse(cached); return options.deserialize ? this.deserialize(json2) : json2; } } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : void 0, resourceType: type, path: `/${uuid}`, searchParams: options?.params }); this.debug(`Fetching resource ${type} with id ${uuid}.`); const response = await this.fetch(endpoint, { withAuth: options.withAuth, next: options.next, cache: options.cache }); await this.throwIfJsonErrors(response, "Error while fetching resource: "); const json = await response.json(); if (options.withCache) { await this.cache.set(options.cacheKey, JSON.stringify(json)); } return options.deserialize ? this.deserialize(json) : json; } /** * Fetches a resource of the specified type by its path. * * @param {string} path The path of the resource. Example: `/blog/slug-for-article`. * @param { { isVersionable?: boolean } & JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request. * - isVersionable: Set to true if you're fetching the revision for a resource. Automatically set to true for node entity types * @returns {Promise<T>} The fetched resource. * @requires Decoupled Router module * @example * Get a page by path * ``` * const node = await drupal.getResourceByPath("/blog/slug-for-article") * ``` * Get the raw JSON:API response * ``` * const { data, meta, links } = await drupal.getResourceByPath( * "/blog/slug-for-article", * { * deserialize: false, * } * ) *``` * Using DrupalNode for a node entity type * ``` * import { DrupalNode } from "next-drupal" * const node = await drupal.getResourceByPath<DrupalNode>( * "/blog/slug-for-article" * ) * ``` */ async getResourceByPath(path, options) { options = { deserialize: true, isVersionable: false, withAuth: this.withAuth, params: {}, ...options }; if (!path) { return null; } path = this.addLocalePrefix(path, { locale: options.locale, defaultLocale: options.defaultLocale }); if (options.params.resourceVersion) { options.isVersionable = true; } const { resourceVersion = "rel:latest-version", ...params } = options.params; if (options.isVersionable) { params.resourceVersion = resourceVersion; } const resourceParams = stringify2(params); const payload = [ { requestId: "router", action: "view", uri: `/router/translate-path?path=${path}&_format=json`, headers: { Accept: "application/vnd.api+json" } }, { requestId: "resolvedResource", action: "view", uri: `{{router.body@$.jsonapi.individual}}?${resourceParams.toString()}`, waitFor: ["router"] } ]; const subrequestsEndpoint = this.addLocalePrefix("/subrequests", { locale: options.locale, defaultLocale: options.defaultLocale }); const endpoint = this.buildUrl(subrequestsEndpoint, { _format: "json" }).toString(); this.debug(`Fetching resource by path, ${path}.`); const response = await this.fetch(endpoint, { method: "POST", credentials: "include", redirect: "follow", body: JSON.stringify(payload), withAuth: options.withAuth, next: options.next, cache: options.cache }); const errorMessagePrefix = "Error while fetching resource by path:"; if (response.status !== 207) { const errors = await this.getErrorsFromResponse(response); throw new JsonApiErrors(errors, response.status, errorMessagePrefix); } const json = await response.json(); if (!json?.["resolvedResource#uri{0}"]?.body) { const status = json?.router?.headers?.status?.[0]; if (status === 404) { return null; } const message = json?.router?.body && JSON.parse(json.router.body)?.message || "Unknown error"; throw new JsonApiErrors(message, status, errorMessagePrefix); } const data = JSON.parse(json["resolvedResource#uri{0}"]?.body); if (data.errors) { const status = json?.["resolvedResource#uri{0}"]?.headers?.status?.[0]; this.logOrThrowError( new JsonApiErrors(data.errors, status, errorMessagePrefix) ); } return options.deserialize ? this.deserialize(data) : data; } /** * Fetches a collection of resources of the specified type. * * @param {string} type The type of the resources. Example: `node--article` or `user--user`. * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request. * - deserialize: Set to false to return the raw JSON:API response * @returns {Promise<T>} The fetched collection of resources. * @example * Get all articles * ``` * const articles = await drupal.getResourceCollection("node--article") * ``` * Using filters * ``` * const publishedArticles = await drupal.getResourceCollection("node--article", { * params: { * "filter[status]": "1", * }, * }) * ``` * Get the raw JSON:API response * ``` * const { data, meta, links } = await drupal.getResourceCollection("node--page", { * deserialize: false, * }) * ``` * Using TypeScript with DrupalNode for a node entity type * ``` * import { DrupalNode } from "next-drupal" * const nodes = await drupal.getResourceCollection<DrupalNode[]>("node--article") * ``` */ async getResourceCollection(type, options) { options = { withAuth: this.withAuth, deserialize: true, ...options }; const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : void 0, resourceType: type, searchParams: options?.params }); this.debug(`Fetching resource collection of type ${type}.`); const response = await this.fetch(endpoint, { withAuth: options.withAuth, next: options.next, cache: options.cache }); await this.throwIfJsonErrors( response, "Error while fetching resource collection: " ); const json = await response.json(); return options.deserialize ? this.deserialize(json) : json; } /** * Fetches path segments for a collection of resources of the specified types. * * @param {string | string[]} types The types of the resources. * @param {JsonApiOptions & JsonApiWithAuthOption & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<{ path: string, type: string, locale: Locale, segments: string[] }[]>} The fetched path segments. */ async getResourceCollectionPathSegments(types, options) { options = { withAuth: this.withAuth, pathPrefix: "", params: {}, ...options }; if (typeof types === "string") { types = [types]; } const paths = await Promise.all( types.map(async (type) => { const params = { [`fields[${type}]`]: "path", ...options?.params }; const locales = options?.locales?.length ? options.locales : [void 0]; return Promise.all( locales.map(async (locale) => { let opts = { params, withAuth: options.withAuth, next: options.next, cache: options.cache }; if (locale) { opts = { ...opts, deserialize: true, locale, defaultLocale: options.defaultLocale }; } const resources = await this.getResourceCollection(type, opts); return resources.map((resource) => { return resource?.path?.alias === this.frontPage ? ( /* c8 ignore next */ "/" ) : resource?.path?.alias; }).filter(Boolean).map((path) => { let segmentPath = path; if (options.pathPrefix && (segmentPath.startsWith( `${options.pathPrefix}/` ) || segmentPath === options.pathPrefix)) { segmentPath = segmentPath.slice(options.pathPrefix.length); } const segments = segmentPath.split("/").filter(Boolean); return { path, type, locale, segments }; }); }) ); }) ); return paths.flat(2); } /** * Translates a path to a DrupalTranslatedPath object. * * @param {string} path The resource path. Example: `/blog/slug-for-article`. * @param {JsonApiWithAuthOption & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<DrupalTranslatedPath | null>} The translated path. * @requires Decoupled Router module * @example * Get info about a `/blog/slug-for-article` path * ```ts * const path = await drupal.translatePath("/blog/slug-for-article") * ``` */ async translatePath(path, options) { options = { withAuth: this.withAuth, ...options }; const endpoint = this.buildUrl("/router/translate-path", { path }).toString(); this.debug(`Fetching translated path, ${path}.`); const response = await this.fetch(endpoint, { withAuth: options.withAuth, next: options.next, cache: options.cache }); if (response.status === 404) { return null; } await this.throwIfJsonErrors(response); return await response.json(); } /** * Fetches the JSON:API index. * * @param {Locale} locale The locale for the request. * @param {JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<JsonApiResponse>} The JSON:API index. */ async getIndex(locale, options) { const endpoint = await this.buildEndpoint({ locale }); this.debug(`Fetching JSON:API index.`); const response = await this.fetch(endpoint, { // As per https://www.drupal.org/node/2984034 /jsonapi is public. withAuth: false, next: options?.next, cache: options?.cache }); await this.throwIfJsonErrors( response, `Failed to fetch JSON:API index at ${endpoint}: ` ); return await response.json(); } /** * Builds an endpoint URL for the specified parameters. * * @param {Parameters<NextDrupalBase["buildEndpoint"]>[0] & { resourceType?: string }} params The parameters for the endpoint. * @returns {Promise<string>} The built endpoint URL. */ async buildEndpoint({ locale = "", resourceType = "", path = "", searchParams } = {}) { let localeSegment = locale ? `/${locale}` : ""; let apiSegment = this.apiPrefix; let resourceSegment = ""; if (resourceType) { if (this.useDefaultEndpoints) { const [id, bundle] = resourceType.split("--"); resourceSegment = `/${id}` + (bundle ? `/${bundle}` : ""); } else { resourceSegment = (await this.fetchResourceEndpoint(resourceType, locale)).pathname; localeSegment = ""; apiSegment = ""; } } if (path && !path.startsWith("/")) { path = `/${path}`; } return this.buildUrl( `${localeSegment}${apiSegment}${resourceSegment}${path}`, searchParams ).toString(); } /** * Fetches the endpoint URL for the specified resource type. * * @param {string} type The type of the resource. * @param {Locale} locale The locale for the request. * @returns {Promise<URL>} The fetched endpoint URL. */ async fetchResourceEndpoint(type, locale) { const index = await this.getIndex(locale); const link = index.links?.[type]; if (!link) { throw new Error(`Resource of type '${type}' not found.`); } const url = new URL(link.href); if (locale && !url.pathname.startsWith(`/${locale}`)) { url.pathname = `/${locale}${url.pathname}`; } return url; } /** * Fetches a menu by its name. * * @param {string} menuName The name of the menu. Example: `main` or `footer`. * @param {JsonApiOptions & JsonApiWithCacheOptions & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<{ items: T[], tree: T[] }>} The fetched menu. * - items: An array of `DrupalMenuLinkContent` * - tree: An array of `DrupalMenuLinkContent` with children nested to match the hierarchy from Drupal * @requires JSON:API Menu Items module * @example * Get the `main` menu * ```ts * const { menu, items } = await drupal.getMenu("main") * ``` * * Get the `main` menu using cache * ```ts * const menu = await drupal.getMenu("main", { * withCache: true, * cacheKey: "menu--main", * }) * ``` */ async getMenu(menuName, options) { options = { withAuth: this.withAuth, deserialize: true, params: {}, withCache: false, ...options }; if (options.withCache) { const cached = await this.cache.get(options.cacheKey); if (cached) { this.debug(`Returning cached menu items for ${menuName}.`); return JSON.parse(cached); } } const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : void 0, resourceType: "menu_items", path: menuName, searchParams: options.params }); this.debug(`Fetching menu items for ${menuName}.`); const response = await this.fetch(endpoint, { withAuth: options.withAuth, next: options.next, cache: options.cache }); await this.throwIfJsonErrors(response, "Error while fetching menu items: "); const data = await response.json(); const items = options.deserialize ? this.deserialize(data) : ( /* c8 ignore next */ data ); const tree = new DrupalMenuTree(items); const menu = { items, tree: tree.length ? tree : void 0 }; if (options.withCache) { await this.cache.set(options.cacheKey, JSON.stringify(menu)); } return menu; } /** * Fetches a view by its name. * * @param {string} name The name of the view and the display id. Example: `articles--promoted`. * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<DrupalView<T>>} The fetched view. * @requires JSON:API Views module * @example * Get a view named `articles` and display id `promoted` * ```ts * const view = await drupal.getView("articles--promoted") * ``` * * Using sparse fieldsets to only fetch the title and body fields * ```ts * const view = await drupal.getView("articles--promoted", { * params: { * fields: { * "node--article": "title,body", * }, * }, * }) * ``` * * Using TypeScript with DrupalNode for a node entity type * ```ts * import { DrupalNode } from "next-drupal" * * const view = await drupal.getView<DrupalNode>("articles--promoted") * ``` */ async getView(name, options) { options = { withAuth: this.withAuth, deserialize: true, params: {}, ...options }; const [viewId, displayId] = name.split("--"); const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : void 0, path: `/views/${viewId}/${displayId}`, searchParams: options.params }); this.debug(`Fetching view, ${viewId}.${displayId}.`); const response = await this.fetch(endpoint, { withAuth: options.withAuth, next: options.next, cache: options.cache }); await this.throwIfJsonErrors(response, "Error while fetching view: "); const data = await response.json(); const results = options.deserialize ? this.deserialize(data) : data; return { id: name, results, meta: data.meta, links: data.links }; } /** * Fetches a search index by its name. * * @param {string} name The name of the search index. * @param {JsonApiOptions & JsonApiWithNextFetchOptions} options Options for the request. * @returns {Promise<T>} The fetched search index. * @requires JSON:API Search API module * @example * Get search results from an index named `articles` * ```ts * const results = await drupal.getSearchIndex("articles") * ``` * * Using TypeScript with DrupalNode for a node entity type * ```ts * import { DrupalNode } from "next-drupal" * * const results = await drupal.getSearchIndex<DrupalNode>("articles") * ``` */ async getSearchIndex(name, options) { options = { withAuth: this.withAuth, deserialize: true, ...options }; const endpoint = await this.buildEndpoint({ locale: options?.locale !== options?.defaultLocale ? options.locale : void 0, path: `/index/${name}`, searchParams: options.params }); this.debug(`Fetching search index, ${name}.`); const response = await this.fetch(endpoint, { withAuth: options.withAuth, next: options.next, cache: options.cache }); await this.throwIfJsonErrors( response, "Error while fetching search index: " ); const json = await response.json(); return options.deserialize ? this.deserialize(json) : json; } /** * Deserializes the response body. * * @param {any} body The response body. * @param {any} options Options for deserialization. * @returns {any} The deserialized response body. * @remarks * To provide your own custom deserializer, see the serializer docs. * @example * ```ts * const url = drupal.buildUrl("/jsonapi/node/article", { * sort: "-created", * "fields[node--article]": "title,path", * }) * * const response = await drupal.fetch(url.toString()) * const json = await response.json() * * const resource = drupal.deserialize(json) * ``` */ deserialize(body, options) { if (!body) return null; return this.deserializer(body, options); } /** * Logs or throws an error based on the throwJsonApiErrors flag. * * @param {Error} error The error to log or throw. */ logOrThrowError(error) { if (!this.throwJsonApiErrors) { this.logger.error(error); return; } throw error; } }; // src/next-drupal-pages.ts import { Jsona as Jsona2 } from "jsona"; var NextDrupalPages = class extends NextDrupal { /** * Instantiates a new NextDrupalPages. * * const client = new NextDrupalPages(baseUrl) * * @param {baseUrl} baseUrl The baseUrl of your Drupal site. Do not add the /jsonapi suffix. * @param {options} options Options for the client. See Experiment_DrupalClientOptions. */ constructor(baseUrl, options = {}) { super(baseUrl, options); this.getPathsFromContext = this.getStaticPathsFromContext; const { serializer = new Jsona2(), useDefaultResourceTypeEntry = false, useDefaultEndpoints = null } = o