Path: blob/main/src/execute/ojs/extract-resources.ts
6458 views
/*1* extract-resources.ts2*3* Copyright (C) 2021-2022 Posit Software, PBC4*/56import * as colors from "fmt/colors";7import {8dirname,9fromFileUrl,10relative,11resolve,12} from "../../deno_ral/path.ts";13import { encodeBase64 } from "encoding/base64";14import { lookup } from "media_types/mod.ts";1516import { parseModule } from "observablehq/parser";17import { parse as parseES6 } from "acorn/acorn";1819import { esbuildCommand, esbuildCompile } from "../../core/esbuild.ts";20import { breakQuartoMd } from "../../core/lib/break-quarto-md.ts";2122import { ojsSimpleWalker } from "./ojs-tools.ts";23import {24asMappedString,25mappedConcat,26MappedString,27} from "../../core/mapped-text.ts";28import { QuartoMdCell } from "../../core/lib/break-quarto-md.ts";29import { getNamedLifetime } from "../../core/lifetimes.ts";30import { resourcePath } from "../../core/resources.ts";31import { error } from "../../deno_ral/log.ts";32import { stripColor } from "../../core/lib/external/colors.ts";33import { lines } from "../../core/lib/text.ts";34import { InternalError } from "../../core/lib/error.ts";35import { kRenderServicesLifetime } from "../../config/constants.ts";36import { safeRemoveSync } from "../../deno_ral/fs.ts";3738// ResourceDescription filenames are always project-relative39export interface ResourceDescription {40filename: string;41referent?: string;42// import statements have importPaths, the actual in-ast name used.43// we need that in case of self-contained files to build local resolvers44// correctly.45importPath?: string;46pathType: "relative" | "root-relative";47resourceType: "import" | "FileAttachment";48}4950// resolves a ResourceDescription's filename to its absolute path51export function resolveResourceFilename(52resource: ResourceDescription,53rootDir: string,54): string {55if (resource.pathType == "relative") {56const result = resolve(57rootDir,58dirname(resource.referent!),59resource.filename,60);61return result;62} else if (resource.pathType === "root-relative") {63const result = resolve(64rootDir,65dirname(resource.referent!),66`.${resource.filename}`,67);68return result;69} else {70throw new Error(`Unrecognized pathType ${resource.pathType}`);71}72}7374// drops resources with project-relative filenames75export function uniqueResources(76resourceList: ResourceDescription[],77) {78const result = [];79const uniqResources = new Map<string, ResourceDescription>();80for (const resource of resourceList) {81if (!uniqResources.has(resource.filename)) {82result.push(resource);83uniqResources.set(resource.filename, resource);84}85}86return result;87}8889interface ResolvedES6Path {90pathType: "root-relative" | "relative";91resolvedImportPath: string;92}9394const resolveES6Path = (95path: string,96originDir: string,97projectRoot?: string,98): ResolvedES6Path => {99if (path.startsWith("/")) {100if (projectRoot === undefined) {101return {102pathType: "root-relative",103resolvedImportPath: resolve(originDir, `.${path}`),104};105} else {106return {107pathType: "root-relative",108resolvedImportPath: resolve(projectRoot, `.${path}`),109};110}111} else {112// Here, it's always the case that path.startsWith('.')113return {114pathType: "relative",115resolvedImportPath: resolve(originDir, path),116};117}118};119120interface DirectDependency {121resolvedImportPath: string;122pathType: "relative" | "root-relative";123importPath: string;124}125126/*127* localImports walks the AST of either OJS source code128* or JS source code to extract local imports129*/130// deno-lint-ignore no-explicit-any131const localImports = (parse: any) => {132const result: string[] = [];133ojsSimpleWalker(parse, {134// deno-lint-ignore no-explicit-any135ExportNamedDeclaration(node: any) {136if (node.source?.value) {137const source = node.source?.value as string;138if (source.startsWith("/") || source.startsWith(".")) {139result.push(source);140}141}142},143// deno-lint-ignore no-explicit-any144ImportDeclaration(node: any) {145const source = node.source?.value as string;146if (source.startsWith("/") || source.startsWith(".")) {147result.push(source);148}149},150});151return result;152};153154// Extracts the direct dependencies from a single js, ojs or qmd file155async function directDependencies(156source: MappedString,157fileDir: string,158language: "js" | "ojs" | "qmd",159projectRoot?: string,160): Promise<DirectDependency[]> {161let ast;162if (language === "js") {163try {164ast = parseES6(source.value, {165ecmaVersion: "2020",166sourceType: "module",167});168} catch (e) {169if (!(e instanceof SyntaxError)) {170throw e;171}172return [];173}174} else if (language === "ojs") {175// try to parse the module, and don't chase dependencies in case176// of a parse error. The actual dependencies will be analyzed from the ast177// below.178try {179ast = parseModule(source.value);180} catch (e) {181if (!(e instanceof SyntaxError)) throw e;182return [];183}184} else {185// language === "qmd"186const ojsCellsSrc = (await breakQuartoMd(source))187.cells188.filter((cell: QuartoMdCell) =>189cell.cell_type !== "markdown" &&190cell.cell_type !== "raw" &&191cell.cell_type?.language === "ojs"192)193.flatMap((v: QuartoMdCell) => v.source); // (concat)194return await directDependencies(195mappedConcat(ojsCellsSrc),196fileDir,197"ojs",198projectRoot,199);200}201202return localImports(ast).map((importPath) => {203const { resolvedImportPath, pathType } = resolveES6Path(204importPath,205fileDir,206projectRoot,207);208return {209resolvedImportPath,210pathType,211importPath,212};213});214}215216export async function extractResolvedResourceFilenamesFromQmd(217markdown: MappedString,218mdDir: string,219projectRoot: string,220) {221const pageResources = [];222223for (const cell of (await breakQuartoMd(markdown)).cells) {224if (225cell.cell_type !== "markdown" &&226cell.cell_type !== "raw" &&227cell.cell_type?.language === "ojs"228) {229pageResources.push(230...(await extractResourceDescriptionsFromOJSChunk(231cell.source,232mdDir,233projectRoot,234)),235);236}237}238239// after converting root-relative and relative paths240// all to absolute, we might once again have duplicates.241// We need another uniquing pass here.242const result = new Set<string>();243for (const resource of uniqueResources(pageResources)) {244result.add(resolveResourceFilename(resource, Deno.cwd()));245}246return Array.from(result);247}248249/*250* literalFileAttachments walks the AST to extract the filenames251* in 'FileAttachment(string)' expressions252*/253// deno-lint-ignore no-explicit-any254const literalFileAttachments = (parse: any) => {255const result: string[] = [];256ojsSimpleWalker(parse, {257// deno-lint-ignore no-explicit-any258CallExpression(node: any) {259if (node.callee?.type !== "Identifier") {260return;261}262if (node.callee?.name !== "FileAttachment") {263return;264}265// deno-lint-ignore no-explicit-any266const args = (node.arguments || []) as any[];267if (args.length < 1) {268return;269}270if (args[0]?.type !== "Literal") {271return;272}273result.push(args[0]?.value);274},275});276return result;277};278279/**280* Resolves an import, potentially compiling typescript to javascript in the process281*282* @param file filename283* @param referent referent file284* @param projectRoot project root, if it exists. Used to check for ts dependencies285* that reach outside of project root, in which case we emit an error286* @returns {287* source: string - the resulting source file of the import, used to chase dependencies288* createdResources: ResourceDescription[] - when compilation happens, returns array289* of created files, so that later cleanup is possible.290* }291*/292async function resolveImport(293file: string,294referent: string,295projectRoot: string | undefined,296mdDir: string,297visited?: Set<string>,298): Promise<299{300source: string;301createdResources: ResourceDescription[];302}303> {304visited = visited ?? new Set();305let source: string;306const createdResources: ResourceDescription[] = [];307if (!file.endsWith(".ts") && !file.endsWith(".tsx")) {308try {309source = Deno.readTextFileSync(file);310} catch (_e) {311error(`OJS dependency ${file} (from ${referent}) not found.`);312throw new Error();313}314// file existed, everything is fine.315return {316source,317createdResources,318};319}320321// now for the hard case, it's a typescript import. We:322323// - use esbuild to compile all the dependencies into javascript324// - transform the import statements so they work on the browser325// - place the files in the right locations326// - report the created resources for future cleanup.327328// note that we "lie" about the source of a typescript import.329// we report the javascript compiled source that exists as the source of the created ".js" file330// instead of the ".ts[x]" file (which won't exist in the project output directory).331// We do this because we know that332// the {ojs} cell will be transformed to refer to that ".js" file later.333334projectRoot = projectRoot ?? dirname(referent);335336const deno = Deno.execPath();337const p = new Deno.Command(deno, {338args: [339"check",340file,341"-c",342resourcePath("conf/deno-ts-compile.jsonc"),343`--importmap=${resourcePath("conf/jsx-import-map.json")}`,344],345stderr: "piped",346});347const output = await p.output();348const stderr = output.stderr;349if (!output.success) {350error("Compilation of typescript dependencies in ojs cell failed.");351352let errStr = new TextDecoder().decode(stderr);353const errorLines = lines(stripColor(errStr));354355// offer guidance around deno bug https://github.com/denoland/deno/issues/14723356const denoBugErrorLines = errorLines357.map((l, i) => ({ text: l, line: i }))358.filter((x) =>359x.text.trim().indexOf(360"TS7026 [ERROR]: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.",361) !== -1362);363364console.log(errorLines);365const errorCountRe = /^Found (\d+) errors.$/;366const errorCount = Number(367(errorLines.filter((x) => (x.trim().match(errorCountRe)))[0] ??368"Found 1 errors.").match(errorCountRe)![1],369);370371// attempt to patch the original error message372if (denoBugErrorLines.length > 0) {373const m = errorLines[denoBugErrorLines[0].line + 3].trim().match(374/^.*(file:.+):\d+:\d+$/,375);376if (m === null) {377// this is an internal error, but we do the best we can by simply printing out the378// error as we know it379console.log(errStr);380throw new InternalError("Internal error in deno ojs cell compilation.");381}382383const badFile = fromFileUrl(m[1]);384const badContents = Deno.readTextFileSync(badFile);385if (!badContents.startsWith("/** @jsxImportSource quarto-tsx */")) {386console.log(`387File ${colors.red(badFile)} must start with388389${colors.yellow("/** @jsxImportSource quarto-tsx */")}390391We apologize for the inconvenience; this is a temporary workaround for an upstream bug.392`);393}394395if (denoBugErrorLines.length !== errorCount) {396console.log(`Other compilation errors follow below.\n`);397398let colorErrorLines = lines(errStr);399for (let i = denoBugErrorLines.length - 1; i >= 0; i--) {400colorErrorLines.splice(denoBugErrorLines[i].line, 5);401}402colorErrorLines = colorErrorLines.map((line) => {403if (line.match(errorCountRe)) {404return `Found ${errorCount - denoBugErrorLines.length} errors.`;405}406return line;407});408// skip "check..." since we already printed the file name409errStr = colorErrorLines.slice(1).join("\n");410}411throw new Error();412}413console.log(errStr);414throw new Error();415}416417const localFile = file.replace(/[.]ts$/, ".js").replace(/[.]tsx$/, ".js");418const fileDir = dirname(localFile);419if (!fileDir.startsWith(resolve(projectRoot))) {420error(421`ERROR: File ${file} has typescript import dependency ${localFile},422outside of main folder ${resolve(projectRoot)}.423quarto will only generate javascript files in ${424resolve(projectRoot)425} or subfolders.`,426);427throw new Error();428}429430const jsSource = await esbuildCommand(431[432file,433"--format=esm",434"--sourcemap=inline",435"--jsx-factory=window._ojs.jsx.createElement",436],437"",438fileDir,439);440441if (typeof jsSource === "undefined") {442throw new InternalError(443`esbuild compilation of file ${file} failed`,444);445}446447let fixedSource = jsSource;448let ast: any = undefined;449try {450ast = parseES6(jsSource, {451ecmaVersion: "latest",452sourceType: "module",453});454} catch (e) {455console.error(jsSource);456console.error("Error parsing compiled typescript file.");457throw e;458}459const recursionList: string[] = [];460// deno-lint-ignore no-explicit-any461const patchDeclaration = (node: any) => {462if (463node.source?.value.endsWith(".ts") ||464node.source?.value.endsWith(".tsx")465) {466recursionList.push(node.source.value);467const rawReplacement = JSON.stringify(468node.source.value.replace(/[.]ts$/, ".js").replace(/[.]tsx$/, ".js"),469);470471fixedSource = fixedSource.substring(0, node.source.start) +472rawReplacement + fixedSource.slice(node.source.end);473}474};475// patch the source to import from .js instead of .ts and .tsx476ojsSimpleWalker(ast, {477ExportNamedDeclaration: patchDeclaration,478ImportDeclaration: patchDeclaration,479});480481for (const tsImport of recursionList) {482if (!(visited!.has(tsImport))) {483visited.add(tsImport);484const { createdResources: recursionCreatedResources } =485await resolveImport(486resolve(dirname(file), tsImport),487file,488projectRoot,489mdDir,490visited,491);492createdResources.push(...recursionCreatedResources);493}494}495496const transformedSource = fixedSource;497Deno.writeTextFileSync(localFile, transformedSource);498createdResources.push({499pathType: "relative",500resourceType: "import",501referent,502filename: resolve(dirname(referent!), localFile),503importPath: `./${relative(resolve(mdDir), localFile)}`,504});505506source = Deno.readTextFileSync(localFile);507return { source, createdResources };508}509510export async function extractResourceDescriptionsFromOJSChunk(511ojsSource: MappedString,512mdDir: string,513projectRoot?: string,514) {515let result: ResourceDescription[] = [];516const handled: Set<string> = new Set();517const imports: Map<string, ResourceDescription> = new Map();518519// FIXME get a uuid here520const rootReferent = `${mdDir}/<<root>>.qmd`;521522// we're assuming that we always start in an {ojs} block.523for (524const { resolvedImportPath, pathType, importPath }525of await directDependencies(526ojsSource,527mdDir,528"ojs",529projectRoot,530)531) {532if (!imports.has(resolvedImportPath)) {533const v: ResourceDescription = {534filename: resolvedImportPath,535referent: rootReferent,536pathType,537importPath,538resourceType: "import",539};540result.push(v);541imports.set(resolvedImportPath, v);542}543}544545while (imports.size > 0) {546const [thisResolvedImportPath, importResource]: [547string,548ResourceDescription,549] = imports.entries().next().value!;550imports.delete(thisResolvedImportPath);551if (handled.has(thisResolvedImportPath)) {552continue;553}554handled.add(thisResolvedImportPath);555const resolvedImport = await resolveImport(556thisResolvedImportPath,557importResource.referent!,558projectRoot,559mdDir,560); // Deno.readTextFileSync(thisResolvedImportPath);561if (resolvedImport === undefined) {562console.error(563`WARNING: While following dependencies, could not resolve reference:`,564);565console.error(` Reference: ${importResource.importPath}`);566console.error(` In file: ${importResource.referent}`);567continue;568}569const source = resolvedImport.source;570result.push(...resolvedImport.createdResources);571// if we're in a project, then we need to clean up at end of render-files lifetime572if (projectRoot) {573getNamedLifetime(kRenderServicesLifetime, true)!.attach({574cleanup() {575for (const res of resolvedImport.createdResources) {576// it's possible to include a createdResource more than once if it's used577// more than once, so we could end up with more than one request578// to delete it. Fail gracefully if so.579try {580safeRemoveSync(res.filename);581} catch (e) {582if (!(e instanceof Error)) throw e;583if (e.name !== "NotFound") {584throw e;585}586}587}588return;589},590});591}592let language;593if (594thisResolvedImportPath.endsWith(".js") ||595thisResolvedImportPath.endsWith(".ts") ||596thisResolvedImportPath.endsWith(".tsx")597) {598language = "js";599} else if (thisResolvedImportPath.endsWith(".ojs")) {600language = "ojs";601} else if (thisResolvedImportPath.endsWith(".qmd")) {602language = "qmd";603} else {604throw new Error(605`Unknown language in file "${thisResolvedImportPath}"`,606);607}608609for (610const { resolvedImportPath, pathType, importPath }611of await directDependencies(612asMappedString(source),613dirname(thisResolvedImportPath),614language as ("js" | "ojs" | "qmd"),615projectRoot,616)617) {618if (!imports.has(resolvedImportPath)) {619const v: ResourceDescription = {620filename: resolvedImportPath,621referent: thisResolvedImportPath,622pathType,623importPath,624resourceType: "import",625};626result.push(v);627imports.set(resolvedImportPath, v);628}629}630}631632const fileAttachments = [];633for (const importFile of result) {634if (importFile.filename.endsWith(".ojs")) {635try {636const ast = parseModule(Deno.readTextFileSync(importFile.filename));637for (const attachment of literalFileAttachments(ast)) {638fileAttachments.push({639filename: attachment,640referent: importFile.filename,641});642}643} catch (e) {644if (!(e instanceof SyntaxError)) {645throw e;646}647}648}649}650// also do it for the current .ojs chunk.651try {652const ast = parseModule(ojsSource.value);653for (const attachment of literalFileAttachments(ast)) {654fileAttachments.push({655filename: attachment,656referent: rootReferent,657});658}659} catch (e) {660// ignore parse errors661if (!(e instanceof SyntaxError)) {662throw e;663}664}665666// while traversing the reference graph, we want to667// keep around the ".qmd" references which arise from668// import ... from "[...].qmd". But we don't want669// qmd files to end up as actual resources to be copied670// to _site, so we filter them out here.671//672// similarly, we filter out ".ts" and ".tsx" imports, since what673// we need are the generated ".js" ones.674675result = result.filter((description) =>676!description.filename.endsWith(".qmd") &&677!description.filename.endsWith(".ts") &&678!description.filename.endsWith(".tsx")679);680681// convert resolved paths to relative paths682result = result.map((description) => {683const { referent, resourceType, importPath, pathType } = description;684let relName = relative(mdDir, description.filename);685if (!relName.startsWith(".")) {686relName = `./${relName}`;687}688return {689filename: relName,690referent,691importPath,692pathType,693resourceType,694};695});696697result.push(...fileAttachments.map(({ filename, referent }) => {698let pathType;699if (filename.startsWith("/")) {700pathType = "root-relative";701} else {702pathType = "relative";703}704705// FIXME why can't the TypeScript typechecker realize this cast is unneeded?706// it complains about pathType and resourceType being strings707// rather than one of their two respectively allowed values.708return ({709referent,710filename,711pathType,712resourceType: "FileAttachment",713}) as ResourceDescription;714}));715716return result;717}718719/* creates a list of [project-relative-name, data-url] values suitable720* for inclusion in self-contained files721*/722export async function makeSelfContainedResources(723resourceList: ResourceDescription[],724wd: string,725) {726const asDataURL = (727content: ArrayBuffer | string,728mimeType: string,729) => {730const b64Src = encodeBase64(content);731return `data:${mimeType};base64,${b64Src}`;732};733734const uniqResources = uniqueResources(resourceList);735736const jsFiles = uniqResources.filter((r) =>737r.resourceType === "import" && r.filename.endsWith(".js")738);739const ojsFiles = uniqResources.filter((r) =>740r.resourceType === "import" && r.filename.endsWith("ojs")741);742const attachments = uniqResources.filter((r) => r.resourceType !== "import");743744const jsModuleResolves = [];745if (jsFiles.length > 0) {746const bundleInput = jsFiles747.map((r) => `export * from "${r.filename}";`)748.join("\n");749const es6BundledModule = await esbuildCompile(750bundleInput,751wd,752["--target=es2018"],753);754755const jsModule = asDataURL(756es6BundledModule as string,757"application/javascript",758);759jsModuleResolves.push(...jsFiles.map((f) => [f.importPath, jsModule])); // inefficient but browser caching makes it correct760}761762const result = [763...jsModuleResolves,764...ojsFiles.map(765(f) => [766// FIXME is this one also wrong?767f.importPath,768asDataURL(769Deno.readTextFileSync(f.filename),770"application/ojs-javascript",771),772],773),774...attachments.map(775(f) => {776const resolvedFileName = resolveResourceFilename(f, Deno.cwd());777const mimeType = lookup(resolvedFileName) ||778"application/octet-stream";779return [780f.filename,781asDataURL(782Deno.readFileSync(resolvedFileName).buffer,783mimeType,784),785];786},787),788];789return result;790}791792793