Path: blob/main/src/command/render/pandoc-dependencies-html.ts
3583 views
/*1* pandoc-dependencies-html.ts2*3* Copyright (C) 2020-2022 Posit Software, PBC4*/56import { basename, dirname, join } from "../../deno_ral/path.ts";78import * as ld from "../../core/lodash.ts";910import { Document, Element, NodeType } from "../../core/deno-dom.ts";1112import { pathWithForwardSlashes, safeExistsSync } from "../../core/path.ts";1314import {15DependencyFile,16DependencyServiceWorker,17Format,18FormatDependency,19FormatExtras,20kDependencies,21} from "../../config/types.ts";22import { kIncludeAfterBody, kIncludeInHeader } from "../../config/constants.ts";23import { TempContext } from "../../core/temp.ts";24import { lines } from "../../core/lib/text.ts";25import { copyFileIfNewer } from "../../core/copy.ts";26import {27appendDependencies,28HtmlAttachmentDependency,29HtmlFormatDependency,30} from "./pandoc-dependencies.ts";31import { fixupCssReferences, isCssFile } from "../../core/css.ts";3233import { ensureDirSync } from "../../deno_ral/fs.ts";34import { ProjectContext } from "../../project/types.ts";35import { projectOutputDir } from "../../project/project-shared.ts";36import { insecureHash } from "../../core/hash.ts";37import { safeCloneDeep } from "../../core/safe-clone-deep.ts";3839export async function writeDependencies(40dependenciesFile: string,41extras: FormatExtras,42) {43if (extras.html?.[kDependencies]) {44const dependencies: HtmlFormatDependency[] = extras.html[kDependencies]!45.map((dep) => {46return {47type: "html",48content: dep,49};50});5152await appendDependencies(dependenciesFile, dependencies);53}54}5556export function readAndInjectDependencies(57dependenciesFile: string,58inputDir: string,59libDir: string,60doc: Document,61project?: ProjectContext,62) {63const dependencyJsonStream = Deno.readTextFileSync(dependenciesFile);64const htmlDependencies: FormatDependency[] = [];65const htmlAttachments: HtmlAttachmentDependency[] = [];66lines(dependencyJsonStream).forEach((json) => {67if (json) {68const dependency = JSON.parse(json);69if (dependency.type === "html") {70htmlDependencies.push(dependency.content);71} else if (dependency.type === "html-attachment") {72htmlAttachments.push(dependency);73}74}75});7677const injectedDependencies = [];78if (htmlDependencies.length > 0) {79const injector = domDependencyInjector(doc);80const injected = processHtmlDependencies(81htmlDependencies,82inputDir,83libDir,84injector,85project,86);87injectedDependencies.push(...injected);88// Finalize the injection89injector.finalizeInjection();90}9192if (htmlAttachments.length > 0) {93for (const attachment of htmlAttachments) {94// Find the 'parent' dependencies for this attachment95const parentDependency = injectedDependencies.find((dep) => {96return dep.name === attachment.content.name;97});9899if (parentDependency) {100// Compute the target directory101const directoryInfo = targetDirectoryInfo(102inputDir,103libDir,104parentDependency,105);106107// copy the file108copyDependencyFile(109attachment.content.file,110directoryInfo.absolute,111!!parentDependency.external,112);113}114}115}116117// See if there are any script elements that must be relocated118// If so, they will be relocated to the top of the list of scripts that119// are present in the document120const relocateScripts = doc.querySelectorAll("script[data-relocate-top]");121if (relocateScripts.length > 0) {122// find the idea insertion point123const nextSiblingEl = doc.querySelector("head script:first-of-type");124if (nextSiblingEl) {125for (const relocateScript of relocateScripts) {126(relocateScript as Element).removeAttribute("data-relocate-top");127nextSiblingEl.parentElement?.insertBefore(128relocateScript,129nextSiblingEl,130);131}132}133}134135return Promise.resolve({136resources: [],137supporting: [],138});139}140141export function resolveDependencies(142extras: FormatExtras,143inputDir: string,144libDir: string,145temp: TempContext,146project?: ProjectContext,147) {148// deep copy to not mutate caller's object149extras = safeCloneDeep(extras);150151const lines: string[] = [];152const afterBodyLines: string[] = [];153154if (extras.html?.[kDependencies]) {155const injector = lineDependencyInjector(lines, afterBodyLines);156processHtmlDependencies(157extras.html[kDependencies]!,158inputDir,159libDir,160injector,161project,162);163// Finalize the injection164injector.finalizeInjection();165166delete extras.html?.[kDependencies];167168// write to external file169const dependenciesHead = temp.createFile({170prefix: "dependencies",171suffix: ".html",172});173Deno.writeTextFileSync(dependenciesHead, lines.join("\n"));174extras[kIncludeInHeader] = [dependenciesHead].concat(175extras[kIncludeInHeader] || [],176);177178// after body179if (afterBodyLines.length > 0) {180const dependenciesAfter = temp.createFile({181prefix: "dependencies-after",182suffix: ".html",183});184Deno.writeTextFileSync(dependenciesAfter, afterBodyLines.join("\n"));185extras[kIncludeAfterBody] = [dependenciesAfter].concat(186extras[kIncludeAfterBody] || [],187);188}189}190191return extras;192}193194interface HtmlInjector {195injectScript(196href: string,197attribs?: Record<string, string>,198afterBody?: boolean,199): void;200201injectStyle(202href: string,203attribs?: Record<string, string>,204afterBody?: boolean,205): void;206207injectLink(208href: string,209rel: string,210type?: string,211): void;212213injectHtml(html: string): void;214215injectMeta(meta: Record<string, string>): void;216217finalizeInjection(): void;218}219220function processHtmlDependencies(221dependencies: FormatDependency[],222inputDir: string,223libDir: string,224injector: HtmlInjector,225project?: ProjectContext,226) {227const copiedDependencies: FormatDependency[] = [];228for (const dependency of dependencies) {229// Ensure that we copy (and render HTML for) each named dependency only once230if (231copiedDependencies.find((copiedDep) => {232return copiedDep.name === dependency.name;233})234) {235continue;236}237238// provide a format libs (i.e. freezer protected) scope for injected deps239const directoryInfo = targetDirectoryInfo(240inputDir,241libDir,242dependency,243);244245const copyFile = (246file: DependencyFile,247attribs?: Record<string, string>,248afterBody?: boolean,249inject?: (250href: string,251attribs?: Record<string, string>,252afterBody?: boolean,253) => void,254) => {255copyDependencyFile(256file,257directoryInfo.absolute,258dependency.external || false,259);260if (inject) {261const href = join(directoryInfo.relative, file.name);262inject(href, attribs, afterBody);263}264};265266// Process scripts267if (dependency.scripts) {268dependency.scripts.forEach((script) =>269copyFile(270script,271script.attribs,272script.afterBody,273injector.injectScript,274)275);276}277278// Process CSS279if (dependency.stylesheets) {280dependency.stylesheets.forEach((stylesheet) => {281copyFile(282stylesheet,283stylesheet.attribs,284stylesheet.afterBody,285injector.injectStyle,286);287});288}289290// Process Service Workers291if (dependency.serviceworkers) {292dependency.serviceworkers.forEach((serviceWorker) => {293const resolveDestination = (294worker: DependencyServiceWorker,295inputDir: string,296project?: ProjectContext,297) => {298// First make sure there is a destination. If omitted, provide299// a default based upon the context300if (!worker.destination) {301if (project) {302worker.destination = `/${basename(worker.source)}`;303} else {304worker.destination = `${basename(worker.source)}`;305}306}307308// Now return either a project path or an input309// relative path310if (worker.destination.startsWith("/")) {311if (project) {312// This is a project relative path313const projectDir = projectOutputDir(project);314return join(projectDir, worker.destination.slice(1));315} else {316throw new Error(317"A service worker is being provided with a project relative destination path but no valid Quarto project was found.",318);319}320} else {321// this is an input relative path322return join(inputDir, worker.destination);323}324};325326// Compute the path to the destination327const destinationFile = resolveDestination(328serviceWorker,329inputDir,330project,331);332const destinationDir = dirname(destinationFile);333334// Ensure the directory exists and copy the source file335// to the destination336ensureDirSync(destinationDir);337copyFileIfNewer(338serviceWorker.source,339destinationFile,340);341});342}343344// Process head HTML345if (dependency.head) {346injector.injectHtml(dependency.head);347}348349// Link tags350if (dependency.links) {351dependency.links.forEach((link) => {352injector.injectLink(link.href, link.rel, link.type);353});354}355356// Process meta tags357if (dependency.meta) {358injector.injectMeta(dependency.meta);359}360361// Process Resources362if (dependency.resources) {363dependency.resources.forEach((resource) => copyFile(resource));364}365366copiedDependencies.push(dependency);367}368return copiedDependencies;369}370371function copyDependencyFile(372file: DependencyFile,373targetDir: string,374external: boolean,375) {376const targetPath = join(targetDir, file.name);377// If this is a user resource, treat it as a resource (resource ref discovery)378// if this something that we're injecting, just copy it379ensureDirSync(dirname(targetPath));380copyFileIfNewer(file.path, targetPath);381382if (external && isCssFile(file.path)) {383processCssFile(dirname(file.path), targetPath);384}385}386387function targetDirectoryInfo(388inputDir: string,389libDir: string,390dependency: FormatDependency,391) {392// provide a format libs (i.e. freezer protected) scope for injected deps393const targetLibDir = dependency.external394? join(libDir, "quarto-contrib")395: libDir;396397// Directory information for the dependency398const dir = dependency.version399? `${dependency.name}-${dependency.version}`400: dependency.name;401402const relativeTargetDir = join(targetLibDir, dir);403const absoluteTargetDir = join(inputDir, relativeTargetDir);404return {405absolute: absoluteTargetDir,406relative: relativeTargetDir,407};408}409410// fixup root ('/') css references and also copy references to other411// stylesheet or resources (e.g. images) to alongside the destFile412function processCssFile(413srcDir: string,414file: string,415) {416// read the css417const css = Deno.readTextFileSync(file);418const destCss = fixupCssReferences(css, (ref: string) => {419// If the reference points to a real file that exists, go ahead and420// process it421const refPath = join(srcDir, ref);422if (safeExistsSync(refPath)) {423// Just use the current ref path, unless the path includes '..'424// which would allow the path to 'escape' this dependency's directory.425// In that case, generate a unique hash of the path and use that426// as the target folder for the resources427const refDir = dirname(ref);428const targetRef = refDir && refDir !== "." && refDir.includes("..")429? join(insecureHash(dirname(ref)), basename(ref))430: ref;431432// Copy the file and provide the updated href target433const refDestPath = join(dirname(file), targetRef);434copyFileIfNewer(refPath, refDestPath);435return pathWithForwardSlashes(targetRef);436} else {437// Since this doesn't appear to point to a real file, just438// leave it alone439return ref;440}441});442443// write the css if necessary444if (destCss !== css) {445Deno.writeTextFileSync(file, destCss);446}447}448449const kDependencyTarget = "htmldependencies:E3FAD763";450451function domDependencyInjector(452doc: Document,453): HtmlInjector {454// Locates the placeholder target for inserting content455const findTargetComment = () => {456for (const node of doc.head.childNodes) {457if (node.nodeType === NodeType.COMMENT_NODE) {458if (459node.textContent &&460node.textContent.trim() === kDependencyTarget461) {462return node;463}464}465}466467// We couldn't find a placeholder comment, just insert468// the nodes at the front of the head469return doc.head.firstChild;470};471const targetComment = findTargetComment();472473const injectEl = (474el: Element,475attribs?: Record<string, string>,476afterBody?: boolean,477) => {478if (attribs) {479for (const key of Object.keys(attribs)) {480el.setAttribute(key, attribs[key]);481}482}483if (!afterBody) {484doc.head.insertBefore(doc.createTextNode("\n"), targetComment);485doc.head.insertBefore(el, targetComment);486} else {487doc.body.appendChild(el);488doc.body.appendChild(doc.createTextNode("\n"));489}490};491492const injectScript = (493href: string,494attribs?: Record<string, string>,495afterBody?: boolean,496) => {497const scriptEl = doc.createElement("script");498scriptEl.setAttribute("src", pathWithForwardSlashes(href));499injectEl(scriptEl, attribs, afterBody);500};501502const injectStyle = (503href: string,504attribs?: Record<string, string>,505afterBody?: boolean,506) => {507const linkEl = doc.createElement("link");508linkEl.setAttribute("href", pathWithForwardSlashes(href));509linkEl.setAttribute("rel", "stylesheet");510injectEl(linkEl, attribs, afterBody);511};512513const injectLink = (514href: string,515rel: string,516type?: string,517) => {518const linkEl = doc.createElement("link");519linkEl.setAttribute("href", pathWithForwardSlashes(href));520linkEl.setAttribute("rel", rel);521if (type) {522linkEl.setAttribute("type", type);523}524injectEl(linkEl);525};526527const injectHtml = (html: string) => {528const container = doc.createElement("div");529container.innerHTML = html;530for (const childEl of container.children) {531injectEl(childEl);532}533};534535const injectMeta = (meta: Record<string, string>) => {536Object.keys(meta).forEach((key) => {537const metaEl = doc.createElement("meta");538metaEl.setAttribute("name", key);539metaEl.setAttribute("content", meta[key]);540injectEl(metaEl);541});542};543544const finalizeInjection = () => {545// Remove the target comment546if (targetComment) {547targetComment._remove();548}549};550551return {552injectScript,553injectStyle,554injectLink,555injectMeta,556injectHtml,557finalizeInjection,558};559}560561function lineDependencyInjector(562lines: string[],563afterBodyLines: string[],564): HtmlInjector {565const metaTemplate = ld.template(566`<meta name="<%- name %>" content="<%- value %>"/>`,567);568569const scriptTemplate = ld.template(570`<script <%= attribs %> src="<%- href %>"></script>`,571);572573const stylesheetTempate = ld.template(574`<link <%= attribs %> href="<%- href %>" rel="stylesheet" />`,575);576const rawLinkTemplate = ld.template(577`<link href="<%- href %>" rel="<%- rel %>"<% if (type) { %> type="<%- type %>"<% } %> />`,578);579580const inject = (content: string, afterBody?: boolean) => {581if (afterBody) {582afterBodyLines.push(content);583} else {584lines.push(content);585}586};587588const formatAttribs = (attribs?: Record<string, string>) => {589return attribs590? Object.entries(attribs).map((entry) => {591const attrib = `${entry[0]}="${entry[1]}"`;592return attrib;593}).join(" ")594: "";595};596597const injectScript = (598href: string,599attribs?: Record<string, string>,600afterBody?: boolean,601) => {602inject(603scriptTemplate(604{ href: pathWithForwardSlashes(href), attribs: formatAttribs(attribs) },605afterBody,606),607);608};609610const injectStyle = (611href: string,612attribs?: Record<string, string>,613afterBody?: boolean,614) => {615inject(616stylesheetTempate(617{ href: pathWithForwardSlashes(href), attribs: formatAttribs(attribs) },618afterBody,619),620);621};622623const injectLink = (624href: string,625rel: string,626type?: string,627) => {628if (!type) {629type = "";630}631lines.push(632rawLinkTemplate({ href: pathWithForwardSlashes(href), type, rel }),633);634};635636const injectHtml = (html: string) => {637lines.push(html + "\n");638};639640const injectMeta = (meta: Record<string, string>) => {641Object.keys(meta).forEach((name) => {642lines.push(metaTemplate({ name, value: meta[name] }));643});644};645646const finalizeInjection = () => {647};648649return {650injectScript,651injectStyle,652injectLink,653injectMeta,654injectHtml,655finalizeInjection,656};657}658659660