Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/command/render/pandoc-dependencies-html.ts
3583 views
1
/*
2
* pandoc-dependencies-html.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { basename, dirname, join } from "../../deno_ral/path.ts";
8
9
import * as ld from "../../core/lodash.ts";
10
11
import { Document, Element, NodeType } from "../../core/deno-dom.ts";
12
13
import { pathWithForwardSlashes, safeExistsSync } from "../../core/path.ts";
14
15
import {
16
DependencyFile,
17
DependencyServiceWorker,
18
Format,
19
FormatDependency,
20
FormatExtras,
21
kDependencies,
22
} from "../../config/types.ts";
23
import { kIncludeAfterBody, kIncludeInHeader } from "../../config/constants.ts";
24
import { TempContext } from "../../core/temp.ts";
25
import { lines } from "../../core/lib/text.ts";
26
import { copyFileIfNewer } from "../../core/copy.ts";
27
import {
28
appendDependencies,
29
HtmlAttachmentDependency,
30
HtmlFormatDependency,
31
} from "./pandoc-dependencies.ts";
32
import { fixupCssReferences, isCssFile } from "../../core/css.ts";
33
34
import { ensureDirSync } from "../../deno_ral/fs.ts";
35
import { ProjectContext } from "../../project/types.ts";
36
import { projectOutputDir } from "../../project/project-shared.ts";
37
import { insecureHash } from "../../core/hash.ts";
38
import { safeCloneDeep } from "../../core/safe-clone-deep.ts";
39
40
export async function writeDependencies(
41
dependenciesFile: string,
42
extras: FormatExtras,
43
) {
44
if (extras.html?.[kDependencies]) {
45
const dependencies: HtmlFormatDependency[] = extras.html[kDependencies]!
46
.map((dep) => {
47
return {
48
type: "html",
49
content: dep,
50
};
51
});
52
53
await appendDependencies(dependenciesFile, dependencies);
54
}
55
}
56
57
export function readAndInjectDependencies(
58
dependenciesFile: string,
59
inputDir: string,
60
libDir: string,
61
doc: Document,
62
project?: ProjectContext,
63
) {
64
const dependencyJsonStream = Deno.readTextFileSync(dependenciesFile);
65
const htmlDependencies: FormatDependency[] = [];
66
const htmlAttachments: HtmlAttachmentDependency[] = [];
67
lines(dependencyJsonStream).forEach((json) => {
68
if (json) {
69
const dependency = JSON.parse(json);
70
if (dependency.type === "html") {
71
htmlDependencies.push(dependency.content);
72
} else if (dependency.type === "html-attachment") {
73
htmlAttachments.push(dependency);
74
}
75
}
76
});
77
78
const injectedDependencies = [];
79
if (htmlDependencies.length > 0) {
80
const injector = domDependencyInjector(doc);
81
const injected = processHtmlDependencies(
82
htmlDependencies,
83
inputDir,
84
libDir,
85
injector,
86
project,
87
);
88
injectedDependencies.push(...injected);
89
// Finalize the injection
90
injector.finalizeInjection();
91
}
92
93
if (htmlAttachments.length > 0) {
94
for (const attachment of htmlAttachments) {
95
// Find the 'parent' dependencies for this attachment
96
const parentDependency = injectedDependencies.find((dep) => {
97
return dep.name === attachment.content.name;
98
});
99
100
if (parentDependency) {
101
// Compute the target directory
102
const directoryInfo = targetDirectoryInfo(
103
inputDir,
104
libDir,
105
parentDependency,
106
);
107
108
// copy the file
109
copyDependencyFile(
110
attachment.content.file,
111
directoryInfo.absolute,
112
!!parentDependency.external,
113
);
114
}
115
}
116
}
117
118
// See if there are any script elements that must be relocated
119
// If so, they will be relocated to the top of the list of scripts that
120
// are present in the document
121
const relocateScripts = doc.querySelectorAll("script[data-relocate-top]");
122
if (relocateScripts.length > 0) {
123
// find the idea insertion point
124
const nextSiblingEl = doc.querySelector("head script:first-of-type");
125
if (nextSiblingEl) {
126
for (const relocateScript of relocateScripts) {
127
(relocateScript as Element).removeAttribute("data-relocate-top");
128
nextSiblingEl.parentElement?.insertBefore(
129
relocateScript,
130
nextSiblingEl,
131
);
132
}
133
}
134
}
135
136
return Promise.resolve({
137
resources: [],
138
supporting: [],
139
});
140
}
141
142
export function resolveDependencies(
143
extras: FormatExtras,
144
inputDir: string,
145
libDir: string,
146
temp: TempContext,
147
project?: ProjectContext,
148
) {
149
// deep copy to not mutate caller's object
150
extras = safeCloneDeep(extras);
151
152
const lines: string[] = [];
153
const afterBodyLines: string[] = [];
154
155
if (extras.html?.[kDependencies]) {
156
const injector = lineDependencyInjector(lines, afterBodyLines);
157
processHtmlDependencies(
158
extras.html[kDependencies]!,
159
inputDir,
160
libDir,
161
injector,
162
project,
163
);
164
// Finalize the injection
165
injector.finalizeInjection();
166
167
delete extras.html?.[kDependencies];
168
169
// write to external file
170
const dependenciesHead = temp.createFile({
171
prefix: "dependencies",
172
suffix: ".html",
173
});
174
Deno.writeTextFileSync(dependenciesHead, lines.join("\n"));
175
extras[kIncludeInHeader] = [dependenciesHead].concat(
176
extras[kIncludeInHeader] || [],
177
);
178
179
// after body
180
if (afterBodyLines.length > 0) {
181
const dependenciesAfter = temp.createFile({
182
prefix: "dependencies-after",
183
suffix: ".html",
184
});
185
Deno.writeTextFileSync(dependenciesAfter, afterBodyLines.join("\n"));
186
extras[kIncludeAfterBody] = [dependenciesAfter].concat(
187
extras[kIncludeAfterBody] || [],
188
);
189
}
190
}
191
192
return extras;
193
}
194
195
interface HtmlInjector {
196
injectScript(
197
href: string,
198
attribs?: Record<string, string>,
199
afterBody?: boolean,
200
): void;
201
202
injectStyle(
203
href: string,
204
attribs?: Record<string, string>,
205
afterBody?: boolean,
206
): void;
207
208
injectLink(
209
href: string,
210
rel: string,
211
type?: string,
212
): void;
213
214
injectHtml(html: string): void;
215
216
injectMeta(meta: Record<string, string>): void;
217
218
finalizeInjection(): void;
219
}
220
221
function processHtmlDependencies(
222
dependencies: FormatDependency[],
223
inputDir: string,
224
libDir: string,
225
injector: HtmlInjector,
226
project?: ProjectContext,
227
) {
228
const copiedDependencies: FormatDependency[] = [];
229
for (const dependency of dependencies) {
230
// Ensure that we copy (and render HTML for) each named dependency only once
231
if (
232
copiedDependencies.find((copiedDep) => {
233
return copiedDep.name === dependency.name;
234
})
235
) {
236
continue;
237
}
238
239
// provide a format libs (i.e. freezer protected) scope for injected deps
240
const directoryInfo = targetDirectoryInfo(
241
inputDir,
242
libDir,
243
dependency,
244
);
245
246
const copyFile = (
247
file: DependencyFile,
248
attribs?: Record<string, string>,
249
afterBody?: boolean,
250
inject?: (
251
href: string,
252
attribs?: Record<string, string>,
253
afterBody?: boolean,
254
) => void,
255
) => {
256
copyDependencyFile(
257
file,
258
directoryInfo.absolute,
259
dependency.external || false,
260
);
261
if (inject) {
262
const href = join(directoryInfo.relative, file.name);
263
inject(href, attribs, afterBody);
264
}
265
};
266
267
// Process scripts
268
if (dependency.scripts) {
269
dependency.scripts.forEach((script) =>
270
copyFile(
271
script,
272
script.attribs,
273
script.afterBody,
274
injector.injectScript,
275
)
276
);
277
}
278
279
// Process CSS
280
if (dependency.stylesheets) {
281
dependency.stylesheets.forEach((stylesheet) => {
282
copyFile(
283
stylesheet,
284
stylesheet.attribs,
285
stylesheet.afterBody,
286
injector.injectStyle,
287
);
288
});
289
}
290
291
// Process Service Workers
292
if (dependency.serviceworkers) {
293
dependency.serviceworkers.forEach((serviceWorker) => {
294
const resolveDestination = (
295
worker: DependencyServiceWorker,
296
inputDir: string,
297
project?: ProjectContext,
298
) => {
299
// First make sure there is a destination. If omitted, provide
300
// a default based upon the context
301
if (!worker.destination) {
302
if (project) {
303
worker.destination = `/${basename(worker.source)}`;
304
} else {
305
worker.destination = `${basename(worker.source)}`;
306
}
307
}
308
309
// Now return either a project path or an input
310
// relative path
311
if (worker.destination.startsWith("/")) {
312
if (project) {
313
// This is a project relative path
314
const projectDir = projectOutputDir(project);
315
return join(projectDir, worker.destination.slice(1));
316
} else {
317
throw new Error(
318
"A service worker is being provided with a project relative destination path but no valid Quarto project was found.",
319
);
320
}
321
} else {
322
// this is an input relative path
323
return join(inputDir, worker.destination);
324
}
325
};
326
327
// Compute the path to the destination
328
const destinationFile = resolveDestination(
329
serviceWorker,
330
inputDir,
331
project,
332
);
333
const destinationDir = dirname(destinationFile);
334
335
// Ensure the directory exists and copy the source file
336
// to the destination
337
ensureDirSync(destinationDir);
338
copyFileIfNewer(
339
serviceWorker.source,
340
destinationFile,
341
);
342
});
343
}
344
345
// Process head HTML
346
if (dependency.head) {
347
injector.injectHtml(dependency.head);
348
}
349
350
// Link tags
351
if (dependency.links) {
352
dependency.links.forEach((link) => {
353
injector.injectLink(link.href, link.rel, link.type);
354
});
355
}
356
357
// Process meta tags
358
if (dependency.meta) {
359
injector.injectMeta(dependency.meta);
360
}
361
362
// Process Resources
363
if (dependency.resources) {
364
dependency.resources.forEach((resource) => copyFile(resource));
365
}
366
367
copiedDependencies.push(dependency);
368
}
369
return copiedDependencies;
370
}
371
372
function copyDependencyFile(
373
file: DependencyFile,
374
targetDir: string,
375
external: boolean,
376
) {
377
const targetPath = join(targetDir, file.name);
378
// If this is a user resource, treat it as a resource (resource ref discovery)
379
// if this something that we're injecting, just copy it
380
ensureDirSync(dirname(targetPath));
381
copyFileIfNewer(file.path, targetPath);
382
383
if (external && isCssFile(file.path)) {
384
processCssFile(dirname(file.path), targetPath);
385
}
386
}
387
388
function targetDirectoryInfo(
389
inputDir: string,
390
libDir: string,
391
dependency: FormatDependency,
392
) {
393
// provide a format libs (i.e. freezer protected) scope for injected deps
394
const targetLibDir = dependency.external
395
? join(libDir, "quarto-contrib")
396
: libDir;
397
398
// Directory information for the dependency
399
const dir = dependency.version
400
? `${dependency.name}-${dependency.version}`
401
: dependency.name;
402
403
const relativeTargetDir = join(targetLibDir, dir);
404
const absoluteTargetDir = join(inputDir, relativeTargetDir);
405
return {
406
absolute: absoluteTargetDir,
407
relative: relativeTargetDir,
408
};
409
}
410
411
// fixup root ('/') css references and also copy references to other
412
// stylesheet or resources (e.g. images) to alongside the destFile
413
function processCssFile(
414
srcDir: string,
415
file: string,
416
) {
417
// read the css
418
const css = Deno.readTextFileSync(file);
419
const destCss = fixupCssReferences(css, (ref: string) => {
420
// If the reference points to a real file that exists, go ahead and
421
// process it
422
const refPath = join(srcDir, ref);
423
if (safeExistsSync(refPath)) {
424
// Just use the current ref path, unless the path includes '..'
425
// which would allow the path to 'escape' this dependency's directory.
426
// In that case, generate a unique hash of the path and use that
427
// as the target folder for the resources
428
const refDir = dirname(ref);
429
const targetRef = refDir && refDir !== "." && refDir.includes("..")
430
? join(insecureHash(dirname(ref)), basename(ref))
431
: ref;
432
433
// Copy the file and provide the updated href target
434
const refDestPath = join(dirname(file), targetRef);
435
copyFileIfNewer(refPath, refDestPath);
436
return pathWithForwardSlashes(targetRef);
437
} else {
438
// Since this doesn't appear to point to a real file, just
439
// leave it alone
440
return ref;
441
}
442
});
443
444
// write the css if necessary
445
if (destCss !== css) {
446
Deno.writeTextFileSync(file, destCss);
447
}
448
}
449
450
const kDependencyTarget = "htmldependencies:E3FAD763";
451
452
function domDependencyInjector(
453
doc: Document,
454
): HtmlInjector {
455
// Locates the placeholder target for inserting content
456
const findTargetComment = () => {
457
for (const node of doc.head.childNodes) {
458
if (node.nodeType === NodeType.COMMENT_NODE) {
459
if (
460
node.textContent &&
461
node.textContent.trim() === kDependencyTarget
462
) {
463
return node;
464
}
465
}
466
}
467
468
// We couldn't find a placeholder comment, just insert
469
// the nodes at the front of the head
470
return doc.head.firstChild;
471
};
472
const targetComment = findTargetComment();
473
474
const injectEl = (
475
el: Element,
476
attribs?: Record<string, string>,
477
afterBody?: boolean,
478
) => {
479
if (attribs) {
480
for (const key of Object.keys(attribs)) {
481
el.setAttribute(key, attribs[key]);
482
}
483
}
484
if (!afterBody) {
485
doc.head.insertBefore(doc.createTextNode("\n"), targetComment);
486
doc.head.insertBefore(el, targetComment);
487
} else {
488
doc.body.appendChild(el);
489
doc.body.appendChild(doc.createTextNode("\n"));
490
}
491
};
492
493
const injectScript = (
494
href: string,
495
attribs?: Record<string, string>,
496
afterBody?: boolean,
497
) => {
498
const scriptEl = doc.createElement("script");
499
scriptEl.setAttribute("src", pathWithForwardSlashes(href));
500
injectEl(scriptEl, attribs, afterBody);
501
};
502
503
const injectStyle = (
504
href: string,
505
attribs?: Record<string, string>,
506
afterBody?: boolean,
507
) => {
508
const linkEl = doc.createElement("link");
509
linkEl.setAttribute("href", pathWithForwardSlashes(href));
510
linkEl.setAttribute("rel", "stylesheet");
511
injectEl(linkEl, attribs, afterBody);
512
};
513
514
const injectLink = (
515
href: string,
516
rel: string,
517
type?: string,
518
) => {
519
const linkEl = doc.createElement("link");
520
linkEl.setAttribute("href", pathWithForwardSlashes(href));
521
linkEl.setAttribute("rel", rel);
522
if (type) {
523
linkEl.setAttribute("type", type);
524
}
525
injectEl(linkEl);
526
};
527
528
const injectHtml = (html: string) => {
529
const container = doc.createElement("div");
530
container.innerHTML = html;
531
for (const childEl of container.children) {
532
injectEl(childEl);
533
}
534
};
535
536
const injectMeta = (meta: Record<string, string>) => {
537
Object.keys(meta).forEach((key) => {
538
const metaEl = doc.createElement("meta");
539
metaEl.setAttribute("name", key);
540
metaEl.setAttribute("content", meta[key]);
541
injectEl(metaEl);
542
});
543
};
544
545
const finalizeInjection = () => {
546
// Remove the target comment
547
if (targetComment) {
548
targetComment._remove();
549
}
550
};
551
552
return {
553
injectScript,
554
injectStyle,
555
injectLink,
556
injectMeta,
557
injectHtml,
558
finalizeInjection,
559
};
560
}
561
562
function lineDependencyInjector(
563
lines: string[],
564
afterBodyLines: string[],
565
): HtmlInjector {
566
const metaTemplate = ld.template(
567
`<meta name="<%- name %>" content="<%- value %>"/>`,
568
);
569
570
const scriptTemplate = ld.template(
571
`<script <%= attribs %> src="<%- href %>"></script>`,
572
);
573
574
const stylesheetTempate = ld.template(
575
`<link <%= attribs %> href="<%- href %>" rel="stylesheet" />`,
576
);
577
const rawLinkTemplate = ld.template(
578
`<link href="<%- href %>" rel="<%- rel %>"<% if (type) { %> type="<%- type %>"<% } %> />`,
579
);
580
581
const inject = (content: string, afterBody?: boolean) => {
582
if (afterBody) {
583
afterBodyLines.push(content);
584
} else {
585
lines.push(content);
586
}
587
};
588
589
const formatAttribs = (attribs?: Record<string, string>) => {
590
return attribs
591
? Object.entries(attribs).map((entry) => {
592
const attrib = `${entry[0]}="${entry[1]}"`;
593
return attrib;
594
}).join(" ")
595
: "";
596
};
597
598
const injectScript = (
599
href: string,
600
attribs?: Record<string, string>,
601
afterBody?: boolean,
602
) => {
603
inject(
604
scriptTemplate(
605
{ href: pathWithForwardSlashes(href), attribs: formatAttribs(attribs) },
606
afterBody,
607
),
608
);
609
};
610
611
const injectStyle = (
612
href: string,
613
attribs?: Record<string, string>,
614
afterBody?: boolean,
615
) => {
616
inject(
617
stylesheetTempate(
618
{ href: pathWithForwardSlashes(href), attribs: formatAttribs(attribs) },
619
afterBody,
620
),
621
);
622
};
623
624
const injectLink = (
625
href: string,
626
rel: string,
627
type?: string,
628
) => {
629
if (!type) {
630
type = "";
631
}
632
lines.push(
633
rawLinkTemplate({ href: pathWithForwardSlashes(href), type, rel }),
634
);
635
};
636
637
const injectHtml = (html: string) => {
638
lines.push(html + "\n");
639
};
640
641
const injectMeta = (meta: Record<string, string>) => {
642
Object.keys(meta).forEach((name) => {
643
lines.push(metaTemplate({ name, value: meta[name] }));
644
});
645
};
646
647
const finalizeInjection = () => {
648
};
649
650
return {
651
injectScript,
652
injectStyle,
653
injectLink,
654
injectMeta,
655
injectHtml,
656
finalizeInjection,
657
};
658
}
659
660