// deno-lint-ignore-file no-explicit-any
import { posix } from "../deps/path.ts";
import { encodeBase64 } from "../deps/base64.ts";
import { merge } from "../core/utils/object.ts";
import { log } from "../core/utils/log.ts";
import { concurrent } from "../core/utils/concurrent.ts";
import { contentType } from "../deps/media_types.ts";
import { walkUrls } from "../core/utils/css_urls.ts";
import type Site from "../core/site.ts";
import type { Page } from "../core/file.ts";
export interface Options {
/** List of extra attributes to copy if replacing the element */
copyAttributes?: (string | RegExp)[];
/** Whether to include the `sourceURL=inline:...` pragma in the inlined content */
sourceURL?: boolean;
}
// Default options
export const defaults: Options = {
copyAttributes: [/^data-/],
sourceURL: false,
};
const cache = new Map();
const XML_DECLARATION_REGEX = /<\?xml[\s\S]*?\?>/;
const COMMENT_REGEX = //g;
const LINE_BREAK_REGEX = /\r?\n/g;
/**
* A plugin to inline the HTML assets,
* like images, JavaScript, CSS, SVG, etc.
* @see https://lume.land/plugins/inline/
*/
export function inline(userOptions?: Options) {
const options = merge(defaults, userOptions);
return (site: Site) => {
site.process([".html", ".css"], function processInline(pages) {
return concurrent(pages, inline);
});
site.addEventListener("beforeUpdate", () => cache.clear());
const selector = `[inline]`;
async function inline(page: Page) {
if (page.outputPath.endsWith(".css")) {
page.text = await walkUrls(
page.text,
async (url, type) => {
if (type !== "url") {
return url;
}
const [path, query] = url.split("?", 2);
if (!query) {
return url;
}
const queryParams = new URLSearchParams(query);
if (!queryParams.has("inline")) {
return url;
}
// Remove the inline query param to get the real file
queryParams.delete("inline");
const newQuery = queryParams.toString();
url = newQuery ? `${path}?${newQuery}` : path;
return await getContent(url, true);
},
);
return;
}
const templateElements = Array.from(
page.document.querySelectorAll("template"),
).flatMap((template) =>
Array.from(template.content.querySelectorAll(selector))
);
for (
const element of [
...Array.from(page.document.querySelectorAll(selector)),
...templateElements,
]
) {
await runInline(page.data.url, element);
element.removeAttribute("inline");
}
}
function runInline(url: string, element: Element) {
if (element.hasAttribute("href")) {
return element.getAttribute("rel") === "stylesheet"
? inlineStyles(url, element)
: inlineHref(url, element);
}
if (element.hasAttribute("src")) {
return element.nodeName === "SCRIPT"
? inlineScript(url, element)
: inlineSrc(url, element);
}
}
function getContent(path: string, asDataUrl = false) {
const id = JSON.stringify([path, asDataUrl]);
if (!cache.has(id)) {
cache.set(id, readContent(path, asDataUrl));
}
return cache.get(id);
}
async function readContent(url: string, asDataUrl: boolean) {
const { pathname } = site.options.location;
const path = url.startsWith(pathname)
? posix.join("/", url.slice(pathname.length))
: url;
const content = await getFileContent(site, path, asDataUrl);
// Return the raw content or undefined if the file is not found
if (!asDataUrl || !content) {
return content;
}
// Return the data URL
const ext = posix.extname(path);
const type = contentType(ext);
if (!type) {
log.warn(`[Inline plugin] Unknown file format ${path}`);
return;
}
if (url.endsWith(".svg")) {
const text = typeof content === "string"
? content
: new TextDecoder().decode(content);
const code = text
.replace(XML_DECLARATION_REGEX, "")
.replaceAll(COMMENT_REGEX, "")
.replaceAll("#", "%23")
.replaceAll(LINE_BREAK_REGEX, "")
.trim();
return `data:${type};charset=UTF-8,${code}`;
}
return `data:${type};base64,${encodeBase64(content)}`;
}
function migrateAttributes(
from: Element,
to: Element,
attributes: string[],
) {
for (const { name, value } of Array.from(from.attributes)) {
const shouldCopy = [...attributes, ...options.copyAttributes].some(
(attr) => (attr instanceof RegExp ? attr.test(name) : attr === name),
);
if (!shouldCopy) {
continue;
}
if (name == "class") {
to.classList.add(
...value.split(" ").filter((value: string) => value != ""),
);
} else if (!to.hasAttribute(name)) {
to.setAttribute(name, value);
}
}
}
async function inlineStyles(url: string, element: Element) {
const path = getPath(url, element.getAttribute("href")!);
const style = element.ownerDocument!.createElement("style");
migrateAttributes(element, style, ["id", "class", "nonce", "title"]);
try {
let content = await getContent(path);
if (element.hasAttribute("media")) {
content = `@media ${element.getAttribute("media")} { ${content} }`;
}
if (options.sourceURL) {
content += `\n/*# sourceURL=inline:${path} */`;
}
style.innerHTML = content;
element.replaceWith(style);
} catch (cause: any) {
log.error(
`[Inline plugin] Unable to inline the file ${path} in the page ${url} (${cause.message})})`,
);
}
}
async function inlineScript(url: string, element: Element) {
const path = getPath(url, element.getAttribute("src")!);
try {
let content = await getContent(path);
if (options.sourceURL) {
content += `\n//# sourceURL=inline:${path}`;
}
element.textContent = content;
element.removeAttribute("src");
} catch (cause: any) {
log.error(
`[Inline plugin] Unable to inline the file ${path} in the page ${url} (${cause.message})})`,
);
}
}
async function inlineSrc(url: string, element: Element) {
const path = getPath(url, element.getAttribute("src")!);
const ext = posix.extname(path);
try {
if (ext === ".svg") {
const content = await getContent(path);
const div = element.ownerDocument!.createElement("div");
div.innerHTML = content;
const svg = div.firstElementChild;
if (svg) {
const width = parseInt(element.getAttribute("width") || "0");
const height = parseInt(element.getAttribute("height") || "0");
const viewBox = svg.getAttribute("viewBox")?.split(" ");
if (width && height) {
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
} else if (width) {
svg.setAttribute("width", String(width));
if (viewBox?.length === 4) {
const ratio = width / parseInt(viewBox[2]);
svg.setAttribute(
"height",
String(parseInt(viewBox[3]) * ratio),
);
}
} else if (height) {
svg.setAttribute("height", String(height));
if (viewBox?.length === 4) {
const ratio = height / parseInt(viewBox[3]);
svg.setAttribute("width", String(parseInt(viewBox[2]) * ratio));
}
}
migrateAttributes(element, svg, ["id", "class", "width", "height"]);
element.replaceWith(svg);
}
return;
}
element.setAttribute("src", await getContent(path, true));
} catch (cause: any) {
log.error(
`[Inline plugin] Unable to inline the file ${path} in the page ${url} (${cause.message})})`,
);
}
}
async function inlineHref(url: string, element: Element) {
const path = getPath(url, element.getAttribute("href")!);
try {
element.setAttribute("href", await getContent(path, true));
} catch (cause: any) {
log.error(
`[Inline plugin] Unable to inline the file ${path} in the page ${url} (${cause.message})})`,
);
}
}
};
}
/** Returns the content of a file or page */
async function getFileContent(
site: Site,
url: string,
binary: boolean,
): Promise {
const content = await site.getContent(url, binary);
if (!content) {
log.warn(`[Inline plugin] Unable to find the file "${url}"`);
}
return content;
}
function getPath(baseUrl: string, url: string): string {
return posix.join("/", posix.resolve(baseUrl, url));
}
export default inline;