Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 86 additions & 4 deletions src/markdown-negotiation-runtime.cjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
const {
HTML_MEDIA_TYPE,
MARKDOWN_CONTENT_TYPE,
acceptsMarkdown,
MARKDOWN_MEDIA_TYPE,
appendHeaderValue,
isHtmlContentType,
isMarkdownPath,
rankRepresentationTypes,
toMarkdownAssetPath,
} = require("./markdown-negotiation-shared.cjs");

Expand All @@ -19,8 +21,55 @@ function shouldRewriteMarkdownContentType(response) {
return response.ok && !isHtmlContentType(response.headers.get("Content-Type"));
}

const PASSTHROUGH_406_HEADERS = [
"Access-Control-Allow-Origin",
"Access-Control-Allow-Credentials",
"Access-Control-Expose-Headers",
"Content-Security-Policy",
"Link",
"Permissions-Policy",
"Referrer-Policy",
"Report-To",
"Strict-Transport-Security",
"X-Content-Type-Options",
"X-Frame-Options",
"X-Robots-Tag",
"X-XSS-Protection",
];

function notAcceptableResponse({ acceptHeader, method, response }) {
const headers = new Headers();
for (const headerName of PASSTHROUGH_406_HEADERS) {
const headerValue = response.headers.get(headerName);
if (headerValue) {
headers.set(headerName, headerValue);
}
}

headers.set("Cache-Control", "no-store");
headers.set("Content-Type", "text/plain; charset=utf-8");
appendHeaderValue(headers, "Vary", "Accept");

Comment thread
basit3407 marked this conversation as resolved.
const lines = [
"This resource is available in:",
`- ${HTML_MEDIA_TYPE}`,
`- ${MARKDOWN_MEDIA_TYPE}`,
];

if (acceptHeader) {
lines.push("", `You requested: ${acceptHeader}`);
}

return new Response(method === "HEAD" ? null : lines.join("\n"), {
headers,
status: 406,
statusText: "Not Acceptable",
});
}

async function negotiateMarkdownResponse({ assetsFetch, request, response }) {
const url = new URL(request.url);
const acceptHeader = request.headers.get("Accept");

if (isMarkdownPath(url.pathname)) {
if (!shouldRewriteMarkdownContentType(response)) {
Expand All @@ -36,16 +85,37 @@ async function negotiateMarkdownResponse({ assetsFetch, request, response }) {
return response;
}

const preferredRepresentations = rankRepresentationTypes(
acceptHeader,
[HTML_MEDIA_TYPE, MARKDOWN_MEDIA_TYPE],
HTML_MEDIA_TYPE,
);
if (!preferredRepresentations.length) {
return notAcceptableResponse({
acceptHeader,
method: request.method,
response,
});
}

const htmlHeaders = new Headers(response.headers);
appendHeaderValue(htmlHeaders, "Vary", "Accept");

if (!acceptsMarkdown(request.headers.get("Accept"))) {
if (preferredRepresentations[0] === HTML_MEDIA_TYPE) {
return withHeaders(response, htmlHeaders, request.method);
}

const markdownAssetPath = toMarkdownAssetPath(url.pathname);
if (!markdownAssetPath) {
return withHeaders(response, htmlHeaders, request.method);
if (preferredRepresentations.includes(HTML_MEDIA_TYPE)) {
return withHeaders(response, htmlHeaders, request.method);
}

return notAcceptableResponse({
acceptHeader,
method: request.method,
response,
});
}

const markdownRequest = new Request(new URL(markdownAssetPath, url), {
Expand All @@ -55,7 +125,19 @@ async function negotiateMarkdownResponse({ assetsFetch, request, response }) {
const markdownResponse = await assetsFetch(markdownRequest);

if (!markdownResponse.ok) {
return withHeaders(response, htmlHeaders, request.method);
if (!response.ok) {
return withHeaders(response, htmlHeaders, request.method);
}

if (preferredRepresentations.includes(HTML_MEDIA_TYPE)) {
return withHeaders(response, htmlHeaders, request.method);
}

return notAcceptableResponse({
acceptHeader,
method: request.method,
response,
});
Comment thread
basit3407 marked this conversation as resolved.
}

const markdownHeaders = new Headers(markdownResponse.headers);
Expand Down
157 changes: 142 additions & 15 deletions src/markdown-negotiation-shared.cjs
Original file line number Diff line number Diff line change
@@ -1,24 +1,149 @@
const MARKDOWN_CONTENT_TYPE = "text/markdown; charset=utf-8";
const HTML_MEDIA_TYPE = "text/html";
const MARKDOWN_MEDIA_TYPE = "text/markdown";
const MARKDOWN_CONTENT_TYPE = `${MARKDOWN_MEDIA_TYPE}; charset=utf-8`;

function acceptsMarkdown(acceptHeader) {
if (!acceptHeader) {
return false;
function parseAcceptEntries(acceptHeader) {
if (!acceptHeader || !acceptHeader.trim()) {
return [];
}

return acceptHeader.split(",").some((mediaRange) => {
const [type, ...params] = mediaRange.split(";").map((part) => part.trim());
if (type.toLowerCase() !== "text/markdown") {
return false;
return acceptHeader
.split(",")
.map((mediaRange, order) => {
const [type, ...params] = mediaRange.split(";").map((part) => part.trim());
if (!type || !type.includes("/")) {
return null;
}

const qParam = params.find((part) => part.toLowerCase().startsWith("q="));
const quality = qParam ? Number(qParam.slice(2)) : 1;
const q =
Number.isFinite(quality) && quality >= 0 && quality <= 1 ? quality : 0;

return {
order,
q,
type: type.toLowerCase(),
};
})
.filter(Boolean);
}

function getMediaRangeSpecificity(candidateType, mediaRangeType) {
const [candidatePrimaryType, candidateSubtype] = candidateType.split("/");
const [rangePrimaryType, rangeSubtype] = mediaRangeType.split("/");

if (!candidatePrimaryType || !candidateSubtype || !rangePrimaryType || !rangeSubtype) {
return -1;
}

if (rangePrimaryType === "*" && rangeSubtype === "*") {
return 0;
}

if (rangeSubtype === "*" && rangePrimaryType === candidatePrimaryType) {
return 1;
}

if (rangePrimaryType === candidatePrimaryType && rangeSubtype === candidateSubtype) {
return 2;
}

return -1;
}

function getRepresentationRank(representationType, acceptEntries) {
let bestMatch = null;

for (const entry of acceptEntries) {
const specificity = getMediaRangeSpecificity(representationType, entry.type);
if (specificity === -1) {
continue;
}

const qParam = params.find((part) => part.toLowerCase().startsWith("q="));
if (!qParam) {
return true;
if (
!bestMatch ||
specificity > bestMatch.specificity ||
(specificity === bestMatch.specificity && entry.q > bestMatch.q) ||
(specificity === bestMatch.specificity &&
entry.q === bestMatch.q &&
entry.order < bestMatch.order)
) {
bestMatch = {
order: entry.order,
q: entry.q,
specificity,
};
}
}

if (!bestMatch) {
return {
order: Number.POSITIVE_INFINITY,
q: 0,
specificity: -1,
};
}

return bestMatch;
}

function rankRepresentationTypes(
acceptHeader,
availableTypes,
defaultType = availableTypes[0],
) {
const normalizedAvailableTypes = availableTypes.map((type) => type.toLowerCase());
const normalizedDefaultType = defaultType.toLowerCase();
const acceptEntries = parseAcceptEntries(acceptHeader);

if (!acceptEntries.length) {
return normalizedAvailableTypes
.slice()
.sort((a, b) => {
if (a === normalizedDefaultType) {
return -1;
}

if (b === normalizedDefaultType) {
return 1;
}

return normalizedAvailableTypes.indexOf(a) - normalizedAvailableTypes.indexOf(b);
});
}

const quality = Number(qParam.slice(2));
return Number.isFinite(quality) && quality > 0;
});
return normalizedAvailableTypes
.map((type, index) => ({
index,
type,
...getRepresentationRank(type, acceptEntries),
}))
.filter((entry) => entry.q > 0)
.sort((a, b) => {
if (b.q !== a.q) {
return b.q - a.q;
}

if (b.specificity !== a.specificity) {
return b.specificity - a.specificity;
}

if (a.order !== b.order) {
return a.order - b.order;
}

if (a.type === normalizedDefaultType) {
return -1;
}

if (b.type === normalizedDefaultType) {
return 1;
}

return a.index - b.index;
})
.map((entry) => entry.type);
}

function isHtmlContentType(contentType) {
Expand Down Expand Up @@ -75,10 +200,12 @@ function appendHeaderValue(headers, name, value) {
}

module.exports = {
HTML_MEDIA_TYPE,
MARKDOWN_CONTENT_TYPE,
acceptsMarkdown,
MARKDOWN_MEDIA_TYPE,
appendHeaderValue,
isHtmlContentType,
isMarkdownPath,
rankRepresentationTypes,
toMarkdownAssetPath,
};
Loading