Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/execute/ojs/extract-resources.ts
6458 views
1
/*
2
* extract-resources.ts
3
*
4
* Copyright (C) 2021-2022 Posit Software, PBC
5
*/
6
7
import * as colors from "fmt/colors";
8
import {
9
dirname,
10
fromFileUrl,
11
relative,
12
resolve,
13
} from "../../deno_ral/path.ts";
14
import { encodeBase64 } from "encoding/base64";
15
import { lookup } from "media_types/mod.ts";
16
17
import { parseModule } from "observablehq/parser";
18
import { parse as parseES6 } from "acorn/acorn";
19
20
import { esbuildCommand, esbuildCompile } from "../../core/esbuild.ts";
21
import { breakQuartoMd } from "../../core/lib/break-quarto-md.ts";
22
23
import { ojsSimpleWalker } from "./ojs-tools.ts";
24
import {
25
asMappedString,
26
mappedConcat,
27
MappedString,
28
} from "../../core/mapped-text.ts";
29
import { QuartoMdCell } from "../../core/lib/break-quarto-md.ts";
30
import { getNamedLifetime } from "../../core/lifetimes.ts";
31
import { resourcePath } from "../../core/resources.ts";
32
import { error } from "../../deno_ral/log.ts";
33
import { stripColor } from "../../core/lib/external/colors.ts";
34
import { lines } from "../../core/lib/text.ts";
35
import { InternalError } from "../../core/lib/error.ts";
36
import { kRenderServicesLifetime } from "../../config/constants.ts";
37
import { safeRemoveSync } from "../../deno_ral/fs.ts";
38
39
// ResourceDescription filenames are always project-relative
40
export interface ResourceDescription {
41
filename: string;
42
referent?: string;
43
// import statements have importPaths, the actual in-ast name used.
44
// we need that in case of self-contained files to build local resolvers
45
// correctly.
46
importPath?: string;
47
pathType: "relative" | "root-relative";
48
resourceType: "import" | "FileAttachment";
49
}
50
51
// resolves a ResourceDescription's filename to its absolute path
52
export function resolveResourceFilename(
53
resource: ResourceDescription,
54
rootDir: string,
55
): string {
56
if (resource.pathType == "relative") {
57
const result = resolve(
58
rootDir,
59
dirname(resource.referent!),
60
resource.filename,
61
);
62
return result;
63
} else if (resource.pathType === "root-relative") {
64
const result = resolve(
65
rootDir,
66
dirname(resource.referent!),
67
`.${resource.filename}`,
68
);
69
return result;
70
} else {
71
throw new Error(`Unrecognized pathType ${resource.pathType}`);
72
}
73
}
74
75
// drops resources with project-relative filenames
76
export function uniqueResources(
77
resourceList: ResourceDescription[],
78
) {
79
const result = [];
80
const uniqResources = new Map<string, ResourceDescription>();
81
for (const resource of resourceList) {
82
if (!uniqResources.has(resource.filename)) {
83
result.push(resource);
84
uniqResources.set(resource.filename, resource);
85
}
86
}
87
return result;
88
}
89
90
interface ResolvedES6Path {
91
pathType: "root-relative" | "relative";
92
resolvedImportPath: string;
93
}
94
95
const resolveES6Path = (
96
path: string,
97
originDir: string,
98
projectRoot?: string,
99
): ResolvedES6Path => {
100
if (path.startsWith("/")) {
101
if (projectRoot === undefined) {
102
return {
103
pathType: "root-relative",
104
resolvedImportPath: resolve(originDir, `.${path}`),
105
};
106
} else {
107
return {
108
pathType: "root-relative",
109
resolvedImportPath: resolve(projectRoot, `.${path}`),
110
};
111
}
112
} else {
113
// Here, it's always the case that path.startsWith('.')
114
return {
115
pathType: "relative",
116
resolvedImportPath: resolve(originDir, path),
117
};
118
}
119
};
120
121
interface DirectDependency {
122
resolvedImportPath: string;
123
pathType: "relative" | "root-relative";
124
importPath: string;
125
}
126
127
/*
128
* localImports walks the AST of either OJS source code
129
* or JS source code to extract local imports
130
*/
131
// deno-lint-ignore no-explicit-any
132
const localImports = (parse: any) => {
133
const result: string[] = [];
134
ojsSimpleWalker(parse, {
135
// deno-lint-ignore no-explicit-any
136
ExportNamedDeclaration(node: any) {
137
if (node.source?.value) {
138
const source = node.source?.value as string;
139
if (source.startsWith("/") || source.startsWith(".")) {
140
result.push(source);
141
}
142
}
143
},
144
// deno-lint-ignore no-explicit-any
145
ImportDeclaration(node: any) {
146
const source = node.source?.value as string;
147
if (source.startsWith("/") || source.startsWith(".")) {
148
result.push(source);
149
}
150
},
151
});
152
return result;
153
};
154
155
// Extracts the direct dependencies from a single js, ojs or qmd file
156
async function directDependencies(
157
source: MappedString,
158
fileDir: string,
159
language: "js" | "ojs" | "qmd",
160
projectRoot?: string,
161
): Promise<DirectDependency[]> {
162
let ast;
163
if (language === "js") {
164
try {
165
ast = parseES6(source.value, {
166
ecmaVersion: "2020",
167
sourceType: "module",
168
});
169
} catch (e) {
170
if (!(e instanceof SyntaxError)) {
171
throw e;
172
}
173
return [];
174
}
175
} else if (language === "ojs") {
176
// try to parse the module, and don't chase dependencies in case
177
// of a parse error. The actual dependencies will be analyzed from the ast
178
// below.
179
try {
180
ast = parseModule(source.value);
181
} catch (e) {
182
if (!(e instanceof SyntaxError)) throw e;
183
return [];
184
}
185
} else {
186
// language === "qmd"
187
const ojsCellsSrc = (await breakQuartoMd(source))
188
.cells
189
.filter((cell: QuartoMdCell) =>
190
cell.cell_type !== "markdown" &&
191
cell.cell_type !== "raw" &&
192
cell.cell_type?.language === "ojs"
193
)
194
.flatMap((v: QuartoMdCell) => v.source); // (concat)
195
return await directDependencies(
196
mappedConcat(ojsCellsSrc),
197
fileDir,
198
"ojs",
199
projectRoot,
200
);
201
}
202
203
return localImports(ast).map((importPath) => {
204
const { resolvedImportPath, pathType } = resolveES6Path(
205
importPath,
206
fileDir,
207
projectRoot,
208
);
209
return {
210
resolvedImportPath,
211
pathType,
212
importPath,
213
};
214
});
215
}
216
217
export async function extractResolvedResourceFilenamesFromQmd(
218
markdown: MappedString,
219
mdDir: string,
220
projectRoot: string,
221
) {
222
const pageResources = [];
223
224
for (const cell of (await breakQuartoMd(markdown)).cells) {
225
if (
226
cell.cell_type !== "markdown" &&
227
cell.cell_type !== "raw" &&
228
cell.cell_type?.language === "ojs"
229
) {
230
pageResources.push(
231
...(await extractResourceDescriptionsFromOJSChunk(
232
cell.source,
233
mdDir,
234
projectRoot,
235
)),
236
);
237
}
238
}
239
240
// after converting root-relative and relative paths
241
// all to absolute, we might once again have duplicates.
242
// We need another uniquing pass here.
243
const result = new Set<string>();
244
for (const resource of uniqueResources(pageResources)) {
245
result.add(resolveResourceFilename(resource, Deno.cwd()));
246
}
247
return Array.from(result);
248
}
249
250
/*
251
* literalFileAttachments walks the AST to extract the filenames
252
* in 'FileAttachment(string)' expressions
253
*/
254
// deno-lint-ignore no-explicit-any
255
const literalFileAttachments = (parse: any) => {
256
const result: string[] = [];
257
ojsSimpleWalker(parse, {
258
// deno-lint-ignore no-explicit-any
259
CallExpression(node: any) {
260
if (node.callee?.type !== "Identifier") {
261
return;
262
}
263
if (node.callee?.name !== "FileAttachment") {
264
return;
265
}
266
// deno-lint-ignore no-explicit-any
267
const args = (node.arguments || []) as any[];
268
if (args.length < 1) {
269
return;
270
}
271
if (args[0]?.type !== "Literal") {
272
return;
273
}
274
result.push(args[0]?.value);
275
},
276
});
277
return result;
278
};
279
280
/**
281
* Resolves an import, potentially compiling typescript to javascript in the process
282
*
283
* @param file filename
284
* @param referent referent file
285
* @param projectRoot project root, if it exists. Used to check for ts dependencies
286
* that reach outside of project root, in which case we emit an error
287
* @returns {
288
* source: string - the resulting source file of the import, used to chase dependencies
289
* createdResources: ResourceDescription[] - when compilation happens, returns array
290
* of created files, so that later cleanup is possible.
291
* }
292
*/
293
async function resolveImport(
294
file: string,
295
referent: string,
296
projectRoot: string | undefined,
297
mdDir: string,
298
visited?: Set<string>,
299
): Promise<
300
{
301
source: string;
302
createdResources: ResourceDescription[];
303
}
304
> {
305
visited = visited ?? new Set();
306
let source: string;
307
const createdResources: ResourceDescription[] = [];
308
if (!file.endsWith(".ts") && !file.endsWith(".tsx")) {
309
try {
310
source = Deno.readTextFileSync(file);
311
} catch (_e) {
312
error(`OJS dependency ${file} (from ${referent}) not found.`);
313
throw new Error();
314
}
315
// file existed, everything is fine.
316
return {
317
source,
318
createdResources,
319
};
320
}
321
322
// now for the hard case, it's a typescript import. We:
323
324
// - use esbuild to compile all the dependencies into javascript
325
// - transform the import statements so they work on the browser
326
// - place the files in the right locations
327
// - report the created resources for future cleanup.
328
329
// note that we "lie" about the source of a typescript import.
330
// we report the javascript compiled source that exists as the source of the created ".js" file
331
// instead of the ".ts[x]" file (which won't exist in the project output directory).
332
// We do this because we know that
333
// the {ojs} cell will be transformed to refer to that ".js" file later.
334
335
projectRoot = projectRoot ?? dirname(referent);
336
337
const deno = Deno.execPath();
338
const p = new Deno.Command(deno, {
339
args: [
340
"check",
341
file,
342
"-c",
343
resourcePath("conf/deno-ts-compile.jsonc"),
344
`--importmap=${resourcePath("conf/jsx-import-map.json")}`,
345
],
346
stderr: "piped",
347
});
348
const output = await p.output();
349
const stderr = output.stderr;
350
if (!output.success) {
351
error("Compilation of typescript dependencies in ojs cell failed.");
352
353
let errStr = new TextDecoder().decode(stderr);
354
const errorLines = lines(stripColor(errStr));
355
356
// offer guidance around deno bug https://github.com/denoland/deno/issues/14723
357
const denoBugErrorLines = errorLines
358
.map((l, i) => ({ text: l, line: i }))
359
.filter((x) =>
360
x.text.trim().indexOf(
361
"TS7026 [ERROR]: JSX element implicitly has type 'any' because no interface 'JSX.IntrinsicElements' exists.",
362
) !== -1
363
);
364
365
console.log(errorLines);
366
const errorCountRe = /^Found (\d+) errors.$/;
367
const errorCount = Number(
368
(errorLines.filter((x) => (x.trim().match(errorCountRe)))[0] ??
369
"Found 1 errors.").match(errorCountRe)![1],
370
);
371
372
// attempt to patch the original error message
373
if (denoBugErrorLines.length > 0) {
374
const m = errorLines[denoBugErrorLines[0].line + 3].trim().match(
375
/^.*(file:.+):\d+:\d+$/,
376
);
377
if (m === null) {
378
// this is an internal error, but we do the best we can by simply printing out the
379
// error as we know it
380
console.log(errStr);
381
throw new InternalError("Internal error in deno ojs cell compilation.");
382
}
383
384
const badFile = fromFileUrl(m[1]);
385
const badContents = Deno.readTextFileSync(badFile);
386
if (!badContents.startsWith("/** @jsxImportSource quarto-tsx */")) {
387
console.log(`
388
File ${colors.red(badFile)} must start with
389
390
${colors.yellow("/** @jsxImportSource quarto-tsx */")}
391
392
We apologize for the inconvenience; this is a temporary workaround for an upstream bug.
393
`);
394
}
395
396
if (denoBugErrorLines.length !== errorCount) {
397
console.log(`Other compilation errors follow below.\n`);
398
399
let colorErrorLines = lines(errStr);
400
for (let i = denoBugErrorLines.length - 1; i >= 0; i--) {
401
colorErrorLines.splice(denoBugErrorLines[i].line, 5);
402
}
403
colorErrorLines = colorErrorLines.map((line) => {
404
if (line.match(errorCountRe)) {
405
return `Found ${errorCount - denoBugErrorLines.length} errors.`;
406
}
407
return line;
408
});
409
// skip "check..." since we already printed the file name
410
errStr = colorErrorLines.slice(1).join("\n");
411
}
412
throw new Error();
413
}
414
console.log(errStr);
415
throw new Error();
416
}
417
418
const localFile = file.replace(/[.]ts$/, ".js").replace(/[.]tsx$/, ".js");
419
const fileDir = dirname(localFile);
420
if (!fileDir.startsWith(resolve(projectRoot))) {
421
error(
422
`ERROR: File ${file} has typescript import dependency ${localFile},
423
outside of main folder ${resolve(projectRoot)}.
424
quarto will only generate javascript files in ${
425
resolve(projectRoot)
426
} or subfolders.`,
427
);
428
throw new Error();
429
}
430
431
const jsSource = await esbuildCommand(
432
[
433
file,
434
"--format=esm",
435
"--sourcemap=inline",
436
"--jsx-factory=window._ojs.jsx.createElement",
437
],
438
"",
439
fileDir,
440
);
441
442
if (typeof jsSource === "undefined") {
443
throw new InternalError(
444
`esbuild compilation of file ${file} failed`,
445
);
446
}
447
448
let fixedSource = jsSource;
449
let ast: any = undefined;
450
try {
451
ast = parseES6(jsSource, {
452
ecmaVersion: "latest",
453
sourceType: "module",
454
});
455
} catch (e) {
456
console.error(jsSource);
457
console.error("Error parsing compiled typescript file.");
458
throw e;
459
}
460
const recursionList: string[] = [];
461
// deno-lint-ignore no-explicit-any
462
const patchDeclaration = (node: any) => {
463
if (
464
node.source?.value.endsWith(".ts") ||
465
node.source?.value.endsWith(".tsx")
466
) {
467
recursionList.push(node.source.value);
468
const rawReplacement = JSON.stringify(
469
node.source.value.replace(/[.]ts$/, ".js").replace(/[.]tsx$/, ".js"),
470
);
471
472
fixedSource = fixedSource.substring(0, node.source.start) +
473
rawReplacement + fixedSource.slice(node.source.end);
474
}
475
};
476
// patch the source to import from .js instead of .ts and .tsx
477
ojsSimpleWalker(ast, {
478
ExportNamedDeclaration: patchDeclaration,
479
ImportDeclaration: patchDeclaration,
480
});
481
482
for (const tsImport of recursionList) {
483
if (!(visited!.has(tsImport))) {
484
visited.add(tsImport);
485
const { createdResources: recursionCreatedResources } =
486
await resolveImport(
487
resolve(dirname(file), tsImport),
488
file,
489
projectRoot,
490
mdDir,
491
visited,
492
);
493
createdResources.push(...recursionCreatedResources);
494
}
495
}
496
497
const transformedSource = fixedSource;
498
Deno.writeTextFileSync(localFile, transformedSource);
499
createdResources.push({
500
pathType: "relative",
501
resourceType: "import",
502
referent,
503
filename: resolve(dirname(referent!), localFile),
504
importPath: `./${relative(resolve(mdDir), localFile)}`,
505
});
506
507
source = Deno.readTextFileSync(localFile);
508
return { source, createdResources };
509
}
510
511
export async function extractResourceDescriptionsFromOJSChunk(
512
ojsSource: MappedString,
513
mdDir: string,
514
projectRoot?: string,
515
) {
516
let result: ResourceDescription[] = [];
517
const handled: Set<string> = new Set();
518
const imports: Map<string, ResourceDescription> = new Map();
519
520
// FIXME get a uuid here
521
const rootReferent = `${mdDir}/<<root>>.qmd`;
522
523
// we're assuming that we always start in an {ojs} block.
524
for (
525
const { resolvedImportPath, pathType, importPath }
526
of await directDependencies(
527
ojsSource,
528
mdDir,
529
"ojs",
530
projectRoot,
531
)
532
) {
533
if (!imports.has(resolvedImportPath)) {
534
const v: ResourceDescription = {
535
filename: resolvedImportPath,
536
referent: rootReferent,
537
pathType,
538
importPath,
539
resourceType: "import",
540
};
541
result.push(v);
542
imports.set(resolvedImportPath, v);
543
}
544
}
545
546
while (imports.size > 0) {
547
const [thisResolvedImportPath, importResource]: [
548
string,
549
ResourceDescription,
550
] = imports.entries().next().value!;
551
imports.delete(thisResolvedImportPath);
552
if (handled.has(thisResolvedImportPath)) {
553
continue;
554
}
555
handled.add(thisResolvedImportPath);
556
const resolvedImport = await resolveImport(
557
thisResolvedImportPath,
558
importResource.referent!,
559
projectRoot,
560
mdDir,
561
); // Deno.readTextFileSync(thisResolvedImportPath);
562
if (resolvedImport === undefined) {
563
console.error(
564
`WARNING: While following dependencies, could not resolve reference:`,
565
);
566
console.error(` Reference: ${importResource.importPath}`);
567
console.error(` In file: ${importResource.referent}`);
568
continue;
569
}
570
const source = resolvedImport.source;
571
result.push(...resolvedImport.createdResources);
572
// if we're in a project, then we need to clean up at end of render-files lifetime
573
if (projectRoot) {
574
getNamedLifetime(kRenderServicesLifetime, true)!.attach({
575
cleanup() {
576
for (const res of resolvedImport.createdResources) {
577
// it's possible to include a createdResource more than once if it's used
578
// more than once, so we could end up with more than one request
579
// to delete it. Fail gracefully if so.
580
try {
581
safeRemoveSync(res.filename);
582
} catch (e) {
583
if (!(e instanceof Error)) throw e;
584
if (e.name !== "NotFound") {
585
throw e;
586
}
587
}
588
}
589
return;
590
},
591
});
592
}
593
let language;
594
if (
595
thisResolvedImportPath.endsWith(".js") ||
596
thisResolvedImportPath.endsWith(".ts") ||
597
thisResolvedImportPath.endsWith(".tsx")
598
) {
599
language = "js";
600
} else if (thisResolvedImportPath.endsWith(".ojs")) {
601
language = "ojs";
602
} else if (thisResolvedImportPath.endsWith(".qmd")) {
603
language = "qmd";
604
} else {
605
throw new Error(
606
`Unknown language in file "${thisResolvedImportPath}"`,
607
);
608
}
609
610
for (
611
const { resolvedImportPath, pathType, importPath }
612
of await directDependencies(
613
asMappedString(source),
614
dirname(thisResolvedImportPath),
615
language as ("js" | "ojs" | "qmd"),
616
projectRoot,
617
)
618
) {
619
if (!imports.has(resolvedImportPath)) {
620
const v: ResourceDescription = {
621
filename: resolvedImportPath,
622
referent: thisResolvedImportPath,
623
pathType,
624
importPath,
625
resourceType: "import",
626
};
627
result.push(v);
628
imports.set(resolvedImportPath, v);
629
}
630
}
631
}
632
633
const fileAttachments = [];
634
for (const importFile of result) {
635
if (importFile.filename.endsWith(".ojs")) {
636
try {
637
const ast = parseModule(Deno.readTextFileSync(importFile.filename));
638
for (const attachment of literalFileAttachments(ast)) {
639
fileAttachments.push({
640
filename: attachment,
641
referent: importFile.filename,
642
});
643
}
644
} catch (e) {
645
if (!(e instanceof SyntaxError)) {
646
throw e;
647
}
648
}
649
}
650
}
651
// also do it for the current .ojs chunk.
652
try {
653
const ast = parseModule(ojsSource.value);
654
for (const attachment of literalFileAttachments(ast)) {
655
fileAttachments.push({
656
filename: attachment,
657
referent: rootReferent,
658
});
659
}
660
} catch (e) {
661
// ignore parse errors
662
if (!(e instanceof SyntaxError)) {
663
throw e;
664
}
665
}
666
667
// while traversing the reference graph, we want to
668
// keep around the ".qmd" references which arise from
669
// import ... from "[...].qmd". But we don't want
670
// qmd files to end up as actual resources to be copied
671
// to _site, so we filter them out here.
672
//
673
// similarly, we filter out ".ts" and ".tsx" imports, since what
674
// we need are the generated ".js" ones.
675
676
result = result.filter((description) =>
677
!description.filename.endsWith(".qmd") &&
678
!description.filename.endsWith(".ts") &&
679
!description.filename.endsWith(".tsx")
680
);
681
682
// convert resolved paths to relative paths
683
result = result.map((description) => {
684
const { referent, resourceType, importPath, pathType } = description;
685
let relName = relative(mdDir, description.filename);
686
if (!relName.startsWith(".")) {
687
relName = `./${relName}`;
688
}
689
return {
690
filename: relName,
691
referent,
692
importPath,
693
pathType,
694
resourceType,
695
};
696
});
697
698
result.push(...fileAttachments.map(({ filename, referent }) => {
699
let pathType;
700
if (filename.startsWith("/")) {
701
pathType = "root-relative";
702
} else {
703
pathType = "relative";
704
}
705
706
// FIXME why can't the TypeScript typechecker realize this cast is unneeded?
707
// it complains about pathType and resourceType being strings
708
// rather than one of their two respectively allowed values.
709
return ({
710
referent,
711
filename,
712
pathType,
713
resourceType: "FileAttachment",
714
}) as ResourceDescription;
715
}));
716
717
return result;
718
}
719
720
/* creates a list of [project-relative-name, data-url] values suitable
721
* for inclusion in self-contained files
722
*/
723
export async function makeSelfContainedResources(
724
resourceList: ResourceDescription[],
725
wd: string,
726
) {
727
const asDataURL = (
728
content: ArrayBuffer | string,
729
mimeType: string,
730
) => {
731
const b64Src = encodeBase64(content);
732
return `data:${mimeType};base64,${b64Src}`;
733
};
734
735
const uniqResources = uniqueResources(resourceList);
736
737
const jsFiles = uniqResources.filter((r) =>
738
r.resourceType === "import" && r.filename.endsWith(".js")
739
);
740
const ojsFiles = uniqResources.filter((r) =>
741
r.resourceType === "import" && r.filename.endsWith("ojs")
742
);
743
const attachments = uniqResources.filter((r) => r.resourceType !== "import");
744
745
const jsModuleResolves = [];
746
if (jsFiles.length > 0) {
747
const bundleInput = jsFiles
748
.map((r) => `export * from "${r.filename}";`)
749
.join("\n");
750
const es6BundledModule = await esbuildCompile(
751
bundleInput,
752
wd,
753
["--target=es2018"],
754
);
755
756
const jsModule = asDataURL(
757
es6BundledModule as string,
758
"application/javascript",
759
);
760
jsModuleResolves.push(...jsFiles.map((f) => [f.importPath, jsModule])); // inefficient but browser caching makes it correct
761
}
762
763
const result = [
764
...jsModuleResolves,
765
...ojsFiles.map(
766
(f) => [
767
// FIXME is this one also wrong?
768
f.importPath,
769
asDataURL(
770
Deno.readTextFileSync(f.filename),
771
"application/ojs-javascript",
772
),
773
],
774
),
775
...attachments.map(
776
(f) => {
777
const resolvedFileName = resolveResourceFilename(f, Deno.cwd());
778
const mimeType = lookup(resolvedFileName) ||
779
"application/octet-stream";
780
return [
781
f.filename,
782
asDataURL(
783
Deno.readFileSync(resolvedFileName).buffer,
784
mimeType,
785
),
786
];
787
},
788
),
789
];
790
return result;
791
}
792
793