Source: media-type.js

/**
 * Media types and content negotiation (specification §2).
 *
 * Negotiation follows RFC 9110 §12.5.1: the `Accept` header is parsed into ranges
 * with quality values, the ranges are ordered by quality and then by specificity,
 * and the first range that matches a representation the server can produce wins.
 */

/** Readable, indented Links Notation. The default representation. */
export const LINO_CONTENT_TYPE = "text/lino";

/** Readable Links Notation restricted to one line per value. */
export const LINO_LINE_CONTENT_TYPE = "text/lino-line";

/** Type-tagged, base64 Links Notation that preserves object identity. */
export const LINO_COMPACT_CONTENT_TYPE = "text/lino-compact";

/** JSON fallback for clients that cannot speak Links Notation. */
export const JSON_CONTENT_TYPE = "application/json";

/** Problem details served as Links Notation. */
export const LINO_PROBLEM_CONTENT_TYPE = "application/problem+lino";

/** Media type of RFC 9457 problem details expressed as JSON. */
export const JSON_PROBLEM_CONTENT_TYPE = "application/problem+json";

/**
 * Problem-details media type to use for each representation.
 *
 * A `4xx` served as `text/lino` is announced as `application/problem+lino`, which
 * lets a client tell an error body from a successful one without parsing it.
 */
export const PROBLEM_MEDIA_TYPES = {
  [LINO_CONTENT_TYPE]: LINO_PROBLEM_CONTENT_TYPE,
  [JSON_CONTENT_TYPE]: JSON_PROBLEM_CONTENT_TYPE,
};

/** Representation each problem-details media type is encoded as. */
const PROBLEM_BASE_MEDIA_TYPES = {
  [LINO_PROBLEM_CONTENT_TYPE]: LINO_CONTENT_TYPE,
  [JSON_PROBLEM_CONTENT_TYPE]: JSON_CONTENT_TYPE,
};

/**
 * Every representation a server produces, most preferred first.
 *
 * @type {string[]}
 */
export const SUPPORTED_MEDIA_TYPES = [
  LINO_CONTENT_TYPE,
  LINO_LINE_CONTENT_TYPE,
  LINO_COMPACT_CONTENT_TYPE,
  JSON_CONTENT_TYPE,
];

/**
 * Add `charset=utf-8` to a media type so that bytes are unambiguous.
 *
 * @param {string} mediaType - Bare media type
 * @returns {string} Media type with an explicit charset
 */
export function withCharset(mediaType) {
  return `${mediaType}; charset=utf-8`;
}

/**
 * Strip parameters and normalise case, turning a header value into a media type.
 *
 * @param {string} [headerValue] - Raw `Content-Type` header value
 * @returns {string} Bare, lower-cased media type ("" when absent)
 */
export function parseContentType(headerValue) {
  if (!headerValue) {
    return "";
  }
  return headerValue.split(";")[0].trim().toLowerCase();
}

/**
 * Parse an `Accept` header into ranges ordered by preference.
 *
 * @param {string} [headerValue] - Raw `Accept` header value
 * @returns {Array<{type: string, quality: number, specificity: number}>} Ordered ranges
 */
export function parseAccept(headerValue) {
  if (!headerValue || !headerValue.trim()) {
    return [{ type: "*/*", quality: 1, specificity: 0 }];
  }

  const ranges = headerValue
    .split(",")
    .map((part, index) => {
      const [rawType, ...parameters] = part.split(";");
      const type = rawType.trim().toLowerCase();
      if (!type) {
        return null;
      }

      let quality = 1;
      for (const parameter of parameters) {
        const [name, value] = parameter.split("=");
        if (name && name.trim().toLowerCase() === "q") {
          const parsed = Number.parseFloat(value);
          quality = Number.isNaN(parsed) ? 0 : parsed;
        }
      }

      let specificity = 2;
      if (type === "*/*") {
        specificity = 0;
      } else if (type.endsWith("/*")) {
        specificity = 1;
      }

      return { type, quality, specificity, index };
    })
    .filter((range) => range !== null);

  ranges.sort((left, right) => {
    if (right.quality !== left.quality) {
      return right.quality - left.quality;
    }
    if (right.specificity !== left.specificity) {
      return right.specificity - left.specificity;
    }
    return left.index - right.index;
  });

  return ranges.map(({ type, quality, specificity }) => ({
    type,
    quality,
    specificity,
  }));
}

/**
 * Test whether an `Accept` range matches a concrete media type.
 *
 * @param {string} range - Range from an `Accept` header
 * @param {string} mediaType - Concrete media type
 * @returns {boolean} True when the range covers the media type
 */
function rangeMatches(range, mediaType) {
  if (range === "*/*") {
    return true;
  }
  if (range === mediaType) {
    return true;
  }
  if (range.endsWith("/*")) {
    return mediaType.startsWith(`${range.slice(0, -1)}`);
  }
  return false;
}

/**
 * Select the representation to produce for a request.
 *
 * @param {string} [acceptHeader] - Raw `Accept` header value
 * @param {string[]} [supported] - Representations the server can produce
 * @returns {string|null} Selected media type, or null when none is acceptable
 */
export function negotiateMediaType(
  acceptHeader,
  supported = SUPPORTED_MEDIA_TYPES,
) {
  for (const range of parseAccept(acceptHeader)) {
    if (range.quality <= 0) {
      continue;
    }
    const match = supported.find((mediaType) =>
      rangeMatches(range.type, mediaType),
    );
    if (match) {
      return match;
    }
  }
  return null;
}

/**
 * Test whether a request body media type can be decoded.
 *
 * @param {string} mediaType - Bare media type of the request body
 * @returns {boolean} True when the body can be decoded
 */
export function isDecodableMediaType(mediaType) {
  return SUPPORTED_MEDIA_TYPES.includes(normalizeMediaType(mediaType));
}

/**
 * Resolve a media type to the representation it is encoded as.
 *
 * Problem-details types carry the same syntax as their base representation, so
 * they decode with the same codec.
 *
 * @param {string} mediaType - Bare media type
 * @returns {string} Representation media type
 */
export function normalizeMediaType(mediaType) {
  return PROBLEM_BASE_MEDIA_TYPES[mediaType] ?? mediaType;
}

/**
 * Problem-details media type matching a representation.
 *
 * @param {string} mediaType - Representation media type
 * @returns {string} Media type to announce for problem details
 */
export function problemMediaType(mediaType) {
  return PROBLEM_MEDIA_TYPES[mediaType] ?? mediaType;
}