Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/extension/extension.ts
6442 views
1
/*
2
* extension.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { existsSync, walkSync } from "../deno_ral/fs.ts";
8
import { expandGlobSync } from "../core/deno/expand-glob.ts";
9
import { warning } from "../deno_ral/log.ts";
10
import { isSubdir } from "../deno_ral/fs.ts";
11
import { coerce, Range, satisfies } from "semver/mod.ts";
12
13
import {
14
kProjectType,
15
ProjectConfig,
16
ProjectContext,
17
} from "../project/types.ts";
18
19
import {
20
basename,
21
dirname,
22
isAbsolute,
23
join,
24
normalize,
25
relative,
26
} from "../deno_ral/path.ts";
27
import { Metadata, QuartoFilter } from "../config/types.ts";
28
import {
29
kSkipHidden,
30
normalizePath,
31
resolvePathGlobs,
32
safeExistsSync,
33
} from "../core/path.ts";
34
import { toInputRelativePaths } from "../project/project-shared.ts";
35
import { projectType } from "../project/types/project-types.ts";
36
import { mergeConfigs } from "../core/config.ts";
37
import { quartoConfig } from "../core/quarto.ts";
38
39
import {
40
kAuthor,
41
kBuiltInExtNames,
42
kBuiltInExtOrg,
43
kCommon,
44
kExtensionDir,
45
kQuartoRequired,
46
kRevealJSPlugins,
47
kTitle,
48
kVersion,
49
} from "./constants.ts";
50
import { extensionIdString } from "./extension-shared.ts";
51
import {
52
Contributes,
53
Extension,
54
ExtensionContext,
55
ExtensionId,
56
ExtensionOptions,
57
RevealPluginInline,
58
} from "./types.ts";
59
import { ExternalEngine } from "../resources/types/schema-types.ts";
60
61
import { cloneDeep } from "../core/lodash.ts";
62
import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts";
63
import { getExtensionConfigSchema } from "../core/lib/yaml-schema/project-config.ts";
64
import { projectIgnoreGlobs } from "../execute/engine.ts";
65
import { ProjectType } from "../project/types/types.ts";
66
import { copyResourceFile } from "../project/project-resources.ts";
67
import {
68
RevealPlugin,
69
RevealPluginBundle,
70
RevealPluginScript,
71
} from "../format/reveal/format-reveal-plugin-types.ts";
72
import { resourcePath } from "../core/resources.ts";
73
import { warnOnce } from "../core/log.ts";
74
import { existsSync1 } from "../core/file.ts";
75
import { kFormatResources } from "../config/constants.ts";
76
77
// This is where we maintain a list of extensions that have been promoted
78
// to 'built-in' status. If we see these extensions, we will filter them
79
// in favor of the built in functionality
80
const kQuartoExtOrganization = "quarto-ext";
81
const kQuartoExtBuiltIn = ["code-filename", "grouped-tabsets"];
82
83
// Create an extension context that can be used to load extensions
84
// Provides caching such that directories will not be rescanned
85
// pmore than once for an extension context.
86
export function createExtensionContext(): ExtensionContext {
87
const extensionCache: Record<string, Extension[]> = {};
88
89
// Reads all extensions available to an input
90
const extensions = async (
91
input?: string,
92
config?: ProjectConfig,
93
projectDir?: string,
94
options?: ExtensionOptions,
95
): Promise<Extension[]> => {
96
// Load the extensions and resolve extension paths
97
const extensions = await loadExtensions(
98
extensionCache,
99
input,
100
projectDir,
101
);
102
103
const results = Object.values(extensions).filter((ext) =>
104
options?.builtIn !== false || ext.id.organization !== kBuiltInExtOrg
105
);
106
107
return results.map((extension) => {
108
if (input) {
109
return resolveExtensionPaths(extension, input, config);
110
} else {
111
return extension;
112
}
113
});
114
};
115
116
// Reads a specific extension avialable to an input
117
const extension = async (
118
name: string,
119
input: string,
120
config?: ProjectConfig,
121
projectDir?: string,
122
): Promise<Extension | undefined> => {
123
// Load the extension and resolve any paths
124
const unresolved = await loadExtension(name, input, projectDir);
125
return resolveExtensionPaths(unresolved, input, config);
126
};
127
128
const find = async (
129
name: string,
130
input: string,
131
contributes?: Contributes,
132
config?: ProjectConfig,
133
projectDir?: string,
134
options?: ExtensionOptions,
135
): Promise<Extension[]> => {
136
const extId = toExtensionId(name);
137
return findExtensions(
138
await extensions(input, config, projectDir, options),
139
extId,
140
contributes,
141
);
142
};
143
144
return {
145
extension,
146
extensions,
147
find,
148
};
149
}
150
151
// Resolves resources that are provided by a project into the
152
// site_lib directory (for example, a logo that is referenced from)
153
// _extensions/confluence/logo.png
154
// will be copied and resolved to:
155
// site_lib/quarto-contrib/quarto-project/confluence/logo.png
156
export function projectExtensionPathResolver(
157
libDir: string,
158
projectDir: string,
159
) {
160
return (href: string, projectOffset: string) => {
161
const projectRelativeHref = relative(projectOffset, href);
162
163
if (
164
projectRelativeHref.startsWith("_extensions/") ||
165
projectRelativeHref.startsWith("_extensions\\")
166
) {
167
const projectTargetHref = projectRelativeHref.replace(
168
/^_extensions/,
169
`${libDir}/quarto-contrib/quarto-project`,
170
);
171
172
copyResourceFile(
173
projectDir,
174
join(projectDir, projectRelativeHref),
175
join(projectDir, projectTargetHref),
176
);
177
return join(projectOffset, projectTargetHref);
178
}
179
180
return href;
181
};
182
}
183
184
export function filterBuiltInExtensions(
185
extensions: Extension[],
186
) {
187
// First see if there are now built it (quarto organization)
188
// filters that we previously provided by quarto-ext and
189
// filter those out
190
const quartoExts = extensions.filter((ext) => {
191
return ext.id.organization === kBuiltInExtOrg;
192
});
193
194
// quarto-ext extensions that are now built in
195
const nowBuiltInExtensions = extensions?.filter((ext) => {
196
return ext.id.organization === "quarto-ext" &&
197
quartoExts.map((ext) => ext.id.name).includes(ext.id.name);
198
});
199
200
if (nowBuiltInExtensions.length > 0) {
201
// filter out the extensions that have become built in
202
extensions = filterExtensionAndWarn(extensions, nowBuiltInExtensions);
203
}
204
205
return extensions;
206
}
207
208
function filterExtensionAndWarn(
209
extensions: Extension[],
210
filterOutExtensions: Extension[],
211
) {
212
warnToRemoveExtensions(filterOutExtensions);
213
// filter out the extensions that have become built in
214
return extensions.filter((ext) => {
215
return !filterOutExtensions.map((ext) => ext.id.name).includes(
216
ext.id.name,
217
);
218
});
219
}
220
221
function warnToRemoveExtensions(extensions: Extension[]) {
222
// Warn the user
223
const removeCommands = extensions.map((ext) => {
224
return `quarto remove extension ${extensionIdString(ext.id)}`;
225
});
226
warnOnce(
227
`One or more extensions have been built in to Quarto. Please use the following command to remove the unneeded extension:\n ${
228
removeCommands.join("\n ")
229
}`,
230
);
231
}
232
233
export function filterExtensions(
234
extensions: Extension[],
235
extensionId: string,
236
type: string,
237
) {
238
if (extensions && extensions.length > 0) {
239
// First see if there are now built it (quarto organization)
240
// filters that we previously provided by quarto-ext and
241
// filter those out
242
extensions = filterBuiltInExtensions(extensions);
243
244
// First see whether there are more than one 'owned' extensions
245
// which match. This means that the there are two different extension, from two
246
// different orgs that match this simple id - user needs to disambiguate
247
const ownedExtensions = extensions.filter((ext) => {
248
return ext.id.organization !== undefined;
249
}).map((ext) => {
250
return extensionIdString(ext.id);
251
});
252
if (ownedExtensions.length > 1) {
253
// There are more than one extensions with owners, warn
254
// user that they should disambiguate
255
warnOnce(
256
`The ${type} '${extensionId}' matched more than one extension. Please use a full name to disambiguate:\n ${
257
ownedExtensions.join("\n ")
258
}`,
259
);
260
}
261
262
// we periodically build in features that were formerly available from
263
// the quarto-ext org. filter them out here (that allows them to remain
264
// referenced in the yaml so we don't break code in the wild)
265
const oldBuiltInExt = extensions?.filter((ext) => {
266
return (ext.id.organization === kQuartoExtOrganization &&
267
(kQuartoExtBuiltIn.includes(ext.id.name) ||
268
kBuiltInExtNames.includes(ext.id.name)));
269
});
270
if (oldBuiltInExt.length > 0) {
271
return filterExtensionAndWarn(extensions, oldBuiltInExt);
272
} else {
273
return extensions;
274
}
275
} else {
276
return extensions;
277
}
278
}
279
280
// Read git subtree extensions (pattern 3 only)
281
// Looks for top-level directories containing _extensions/ subdirectories
282
export async function readSubtreeExtensions(
283
subtreeDir: string,
284
): Promise<Extension[]> {
285
const extensions: Extension[] = [];
286
287
const topLevelDirs = safeExistsSync(subtreeDir) &&
288
Deno.statSync(subtreeDir).isDirectory
289
? Deno.readDirSync(subtreeDir)
290
: [];
291
292
for (const topLevelDir of topLevelDirs) {
293
if (!topLevelDir.isDirectory) continue;
294
295
const dirPath = join(subtreeDir, topLevelDir.name);
296
const subtreeExtensionsPath = join(dirPath, kExtensionDir);
297
298
if (safeExistsSync(subtreeExtensionsPath)) {
299
// This is a git subtree wrapper - read extensions preserving their natural organization
300
const exts = await readExtensions(subtreeExtensionsPath);
301
extensions.push(...exts);
302
}
303
}
304
305
return extensions;
306
}
307
308
// Loads all extensions for a given input
309
// (note this needs to be sure to return copies from
310
// the cache in the event that the objects are mutated)
311
const loadExtensions = async (
312
cache: Record<string, Extension[]>,
313
input?: string,
314
projectDir?: string,
315
) => {
316
const extensionPath = inputExtensionDirs(input, projectDir);
317
const allExtensions: Record<string, Extension> = {};
318
const subtreePath = builtinSubtreeExtensions();
319
320
for (const extensionDir of extensionPath) {
321
if (cache[extensionDir]) {
322
cache[extensionDir].forEach((ext) => {
323
allExtensions[extensionIdString(ext.id)] = cloneDeep(ext);
324
});
325
} else {
326
// Check if this is the subtree extensions directory
327
const extensions = extensionDir === subtreePath
328
? await readSubtreeExtensions(extensionDir)
329
: await readExtensions(extensionDir);
330
extensions.forEach((extension) => {
331
allExtensions[extensionIdString(extension.id)] = cloneDeep(extension);
332
});
333
cache[extensionDir] = extensions;
334
}
335
}
336
337
return allExtensions;
338
};
339
340
// Loads a single extension using a name (e.g. elsevier or quarto-journals/elsevier)
341
const loadExtension = async (
342
extension: string,
343
input: string,
344
projectDir?: string,
345
): Promise<Extension> => {
346
const extensionId = toExtensionId(extension);
347
const extensionPath = discoverExtensionPath(input, extensionId, projectDir);
348
349
if (extensionPath) {
350
// Find the metadata file, if any
351
const file = extensionFile(extensionPath);
352
if (file) {
353
const extension = await readExtension(extensionId, file);
354
validateExtension(extension);
355
return extension;
356
} else {
357
// This extension doesn't have an _extension file
358
throw new Error(
359
`The extension '${extension}' is missing the expected '_extension.yml' file.`,
360
);
361
}
362
} else {
363
// There is no extension with this name!
364
throw new Error(
365
`Unable to read the extension '${extension}'.\nPlease ensure that you provided the correct id and that the extension is installed.`,
366
);
367
}
368
};
369
370
// Searches extensions for an extension(s) with a specified
371
// Id and which optionally contributes specific extension elements
372
function findExtensions(
373
extensions: Extension[],
374
extensionId: ExtensionId,
375
contributes?: Contributes,
376
) {
377
// Filter the extension based upon what they contribute
378
const exts = extensions.filter((ext) => {
379
if (contributes === "shortcodes" && ext.contributes.shortcodes) {
380
return true;
381
} else if (contributes === "filters" && ext.contributes.filters) {
382
return true;
383
} else if (contributes === "formats" && ext.contributes.formats) {
384
return true;
385
} else if (contributes === "project" && ext.contributes.project) {
386
return true;
387
} else if (contributes === "metadata" && ext.contributes.metadata) {
388
return true;
389
} else if (
390
contributes === kRevealJSPlugins && ext.contributes[kRevealJSPlugins]
391
) {
392
return true;
393
} else if (contributes === "engines" && ext.contributes.engines) {
394
return true;
395
} else {
396
return contributes === undefined;
397
}
398
});
399
400
// First try an exact match
401
if (extensionId.organization) {
402
const exact = exts.filter((ext) => {
403
return (ext.id.name === extensionId.name &&
404
ext.id.organization === extensionId.organization);
405
});
406
if (exact.length > 0) {
407
return exact;
408
}
409
}
410
411
// If there wasn't an exact match, try just using the name
412
const nameMatches = exts.filter((ext) => {
413
return extensionId.name === ext.id.name;
414
});
415
416
// Sort to make the unowned version first
417
const sortedMatches = nameMatches.sort((ext1, _ext2) => {
418
return ext1.id.organization === undefined ? -1 : 1;
419
});
420
421
return sortedMatches;
422
}
423
424
export function extensionProjectType(
425
extension: Extension,
426
config?: ProjectConfig,
427
): ProjectType {
428
if (extension.contributes.project) {
429
const projType = extension.contributes.project?.type as string || "default";
430
return projectType(projType);
431
} else {
432
return projectType(config?.project?.[kProjectType]);
433
}
434
}
435
436
// Fixes up paths for metatadata provided by an extension
437
function resolveExtensionPaths(
438
extension: Extension,
439
input: string,
440
config?: ProjectConfig,
441
) {
442
const inputDir = Deno.statSync(input).isDirectory ? input : dirname(input);
443
444
return toInputRelativePaths(
445
extensionProjectType(extension, config),
446
extension.path,
447
inputDir,
448
extension,
449
kExtensionIgnoreFields,
450
) as unknown as Extension;
451
}
452
453
const kExtensionIgnoreFields = ["biblio-style", "revealjs-plugins"];
454
455
// Read the raw extension information out of a directory
456
// (e.g. read all the extensions from _extensions)
457
export async function readExtensions(
458
extensionsDirectory: string,
459
organization?: string,
460
) {
461
const extensions: Extension[] = [];
462
const extensionDirs = safeExistsSync(extensionsDirectory) &&
463
Deno.statSync(extensionsDirectory).isDirectory
464
? Deno.readDirSync(extensionsDirectory)
465
: [];
466
for (const extensionDir of extensionDirs) {
467
if (extensionDir.isDirectory) {
468
const extFile = extensionFile(
469
join(extensionsDirectory, extensionDir.name),
470
);
471
if (extFile) {
472
// This is a directory that contains an extension
473
// This represents an 'anonymous' extension that doesn't
474
// have an owner
475
const extensionId = { name: extensionDir.name, organization };
476
const extension = await readExtension(
477
extensionId,
478
extFile,
479
);
480
extensions.push(extension);
481
} else if (!organization) {
482
// If we're at the root level and this folder is an extension folder
483
// treat it as an 'owner' and look inside this folder to see if
484
// there are extensions in subfolders. Only read 1 level.
485
const ownedExtensions = await readExtensions(
486
join(extensionsDirectory, extensionDir.name),
487
extensionDir.name,
488
);
489
if (ownedExtensions) {
490
extensions.push(...ownedExtensions);
491
}
492
}
493
}
494
}
495
496
return extensions;
497
}
498
499
export function projectExtensionDirs(project: ProjectContext) {
500
const extensionDirs: string[] = [];
501
for (
502
const walk of expandGlobSync(join(project.dir, "**/_extensions"), {
503
exclude: [...projectIgnoreGlobs(project.dir), "**/.*", "**/.*/**"],
504
})
505
) {
506
extensionDirs.push(walk.path);
507
}
508
return extensionDirs;
509
}
510
511
export function extensionFilesFromDirs(dirs: string[]) {
512
const files: string[] = [];
513
for (const dir of dirs) {
514
for (
515
const walk of walkSync(
516
dir,
517
{
518
includeDirs: false,
519
followSymlinks: false,
520
skip: [kSkipHidden],
521
},
522
)
523
) {
524
files.push(walk.path);
525
}
526
}
527
return files;
528
}
529
530
// Find all the extension directories available for a given input and project
531
// This will recursively search valid extension directories
532
export function inputExtensionDirs(input?: string, projectDir?: string) {
533
const extensionsDirPath = (path: string) => {
534
const extPath = join(path, kExtensionDir);
535
try {
536
if (Deno.statSync(extPath).isDirectory) {
537
return extPath;
538
} else {
539
return undefined;
540
}
541
} catch {
542
return undefined;
543
}
544
};
545
546
const inputDirName = (inputOrDir: string) => {
547
if (Deno.statSync(inputOrDir).isDirectory) {
548
return inputOrDir;
549
} else {
550
return dirname(inputOrDir);
551
}
552
};
553
554
// read extensions (start with built-in)
555
const extensionDirectories: string[] = [
556
builtinExtensions(),
557
builtinSubtreeExtensions(),
558
];
559
if (projectDir && input) {
560
let currentDir = normalizePath(inputDirName(input));
561
do {
562
const extensionPath = extensionsDirPath(currentDir);
563
if (extensionPath) {
564
extensionDirectories.push(extensionPath);
565
}
566
currentDir = dirname(currentDir);
567
} while (isSubdir(projectDir, currentDir) || projectDir === currentDir);
568
return extensionDirectories;
569
} else if (input) {
570
const dir = extensionsDirPath(inputDirName(input));
571
if (dir) {
572
extensionDirectories.push(dir);
573
}
574
} else if (projectDir) {
575
const dir = extensionsDirPath(projectDir);
576
if (dir) {
577
extensionDirectories.push(dir);
578
}
579
}
580
return extensionDirectories;
581
}
582
583
// Finds the path to a specific extension by name/id
584
export function discoverExtensionPath(
585
input: string,
586
extensionId: ExtensionId,
587
projectDir?: string,
588
) {
589
const extensionDirGlobs = [];
590
if (extensionId.organization) {
591
// If there is an organization, always match that exactly
592
extensionDirGlobs.push(
593
`${extensionId.organization}/${extensionId.name}/`,
594
);
595
} else {
596
// Otherwise, match either the exact string (e.g. acm or a wildcard org */acm/)
597
extensionDirGlobs.push(`${extensionId.name}/`);
598
extensionDirGlobs.push(`*/${extensionId.name}/`);
599
}
600
601
const findExtensionDir = (extDir: string, globs: string[]) => {
602
// Find the matching extension for this name (ensuring that an _extension.yml file is present)
603
const paths = resolvePathGlobs(extDir, globs, [], { mode: "strict" })
604
.include.filter((path) => {
605
return extensionFile(path);
606
});
607
608
if (paths.length > 0) {
609
if (paths.length > 1) {
610
warning(
611
`More than one extension is available for ${extensionId.name} in the directory ${extDir}.\nExtensions that match:\n${
612
paths.join("\n")
613
}`,
614
);
615
}
616
return relative(Deno.cwd(), paths[0]);
617
} else {
618
return undefined;
619
}
620
};
621
622
// check for built-in
623
const builtinExtensionDir = findExtensionDir(
624
builtinExtensions(),
625
extensionDirGlobs,
626
);
627
if (builtinExtensionDir) {
628
return builtinExtensionDir;
629
}
630
631
// check for built-in subtree extensions (pattern: extension-subtrees/*/\_extensions/name)
632
const subtreePath = builtinSubtreeExtensions();
633
if (safeExistsSync(subtreePath)) {
634
for (const topLevelDir of Deno.readDirSync(subtreePath)) {
635
if (!topLevelDir.isDirectory) continue;
636
const subtreeExtDir = join(subtreePath, topLevelDir.name, kExtensionDir);
637
if (safeExistsSync(subtreeExtDir)) {
638
const subtreeExtensionDir = findExtensionDir(
639
subtreeExtDir,
640
extensionDirGlobs,
641
);
642
if (subtreeExtensionDir) {
643
return subtreeExtensionDir;
644
}
645
}
646
}
647
}
648
649
// Start in the source directory
650
const sourceDir = Deno.statSync(input).isDirectory ? input : dirname(input);
651
const sourceDirAbs = normalizePath(sourceDir);
652
653
if (projectDir && isSubdir(projectDir, sourceDirAbs)) {
654
let extensionDir;
655
let currentDir = normalize(sourceDirAbs);
656
const projDir = normalize(projectDir);
657
while (!extensionDir) {
658
extensionDir = findExtensionDir(
659
join(currentDir, kExtensionDir),
660
extensionDirGlobs,
661
);
662
if (currentDir == projDir) {
663
break;
664
}
665
currentDir = dirname(currentDir);
666
}
667
return extensionDir;
668
} else {
669
return findExtensionDir(
670
join(sourceDirAbs, kExtensionDir),
671
extensionDirGlobs,
672
);
673
}
674
}
675
676
// Path for built-in extensions
677
function builtinExtensions() {
678
return resourcePath("extensions");
679
}
680
681
// Path for built-in subtree extensions
682
export function builtinSubtreeExtensions() {
683
return resourcePath("extension-subtrees");
684
}
685
686
// Validate the extension
687
function validateExtension(extension: Extension) {
688
let contribCount = 0;
689
const contribs = [
690
extension.contributes.filters,
691
extension.contributes.shortcodes,
692
extension.contributes.formats,
693
extension.contributes.project,
694
extension.contributes[kRevealJSPlugins],
695
extension.contributes.metadata,
696
extension.contributes.engines,
697
];
698
contribs.forEach((contrib) => {
699
if (contrib) {
700
if (Array.isArray(contrib)) {
701
contribCount = contribCount + contrib.length;
702
} else if (typeof contrib === "object") {
703
contribCount = contribCount + Object.keys(contrib).length;
704
}
705
}
706
});
707
708
if (contribCount === 0) {
709
throw new Error(
710
`The extension ${
711
extension.title || extension.id.name
712
} is not valid- it does not contribute anything.`,
713
);
714
}
715
if (
716
extension.quartoVersion &&
717
!satisfies(quartoConfig.version(), extension.quartoVersion)
718
) {
719
throw new Error(
720
`The extension ${
721
extension.title || extension.id.name
722
} is incompatible with this quarto version.
723
724
Extension requires: ${extension.quartoVersion.raw}
725
Quarto version: ${quartoConfig.version()}`,
726
);
727
}
728
}
729
730
// Reads raw extension data
731
async function readExtension(
732
extensionId: ExtensionId,
733
extensionFile: string,
734
): Promise<Extension> {
735
const extensionSchema = await getExtensionConfigSchema();
736
const yaml = (await readAndValidateYamlFromFile(
737
extensionFile,
738
extensionSchema,
739
"YAML Validation Failed",
740
)) as Metadata;
741
742
const readVersionRange = (str: string): Range => {
743
return new Range(str);
744
};
745
746
const contributes = yaml.contributes as Metadata | undefined;
747
748
const title = yaml[kTitle] as string;
749
const author = yaml[kAuthor] as string;
750
const versionRaw = yaml[kVersion] as string | undefined;
751
const quartoVersionRaw = yaml[kQuartoRequired] as string | undefined;
752
const versionParsed = versionRaw ? coerce(versionRaw) : undefined;
753
const quartoVersion = quartoVersionRaw
754
? readVersionRange(quartoVersionRaw)
755
: undefined;
756
const version = versionParsed ? versionParsed : undefined;
757
758
// The directory containing this extension
759
// Paths used should be considered relative to this dir
760
const extensionDirRaw = dirname(extensionFile);
761
const extensionDir = isAbsolute(extensionDirRaw)
762
? extensionDirRaw
763
: join(Deno.cwd(), extensionDirRaw);
764
765
// The formats that are being contributed
766
const formats = contributes?.formats as Metadata ||
767
contributes?.format as Metadata || {};
768
769
// Read any embedded extension
770
const embeddedExtensions = await readExtensions(
771
join(extensionDir, kExtensionDir),
772
);
773
774
// Resolve 'default' specially
775
Object.keys(formats).forEach((key) => {
776
if (formats[key] === "default") {
777
formats[key] = {};
778
}
779
});
780
781
// Process the special 'common' key by merging it
782
// into any key that isn't 'common' and then removing it
783
Object.keys(formats).filter((key) => {
784
return key !== kCommon;
785
}).forEach((key) => {
786
formats[key] = mergeConfigs(
787
formats[kCommon] || {},
788
formats[key],
789
);
790
791
const formatMeta = formats[key] as Metadata;
792
793
if (formatMeta[kFormatResources]) {
794
// Resolve any globs in format resources
795
const resolved = resolvePathGlobs(
796
extensionDir,
797
formatMeta[kFormatResources] as string[],
798
[],
799
{ mode: "strict" },
800
);
801
if (resolved.include.length > 0) {
802
formatMeta[kFormatResources] = resolved.include.map((include) => {
803
return relative(extensionDir, include);
804
});
805
}
806
}
807
808
// If this is a custom writer, set the writer for the format
809
// using the full path to the lua file
810
if (key.endsWith(".lua")) {
811
const fullPath = join(extensionDir, key);
812
if (existsSync(fullPath)) {
813
formatMeta.writer = fullPath;
814
}
815
}
816
817
// Resolve shortcodes and filters (these might come from embedded extension)
818
// Note that resolving will throw if the extension cannot be resolved
819
formatMeta.shortcodes = (formatMeta.shortcodes as string[] || []).flatMap((
820
shortcode,
821
) => {
822
return resolveShortcode(embeddedExtensions, extensionDir, shortcode);
823
});
824
formatMeta.filters = (formatMeta.filters as QuartoFilter[] || []).flatMap(
825
(filter) => {
826
return resolveFilter(embeddedExtensions, extensionDir, filter);
827
},
828
);
829
formatMeta[kRevealJSPlugins] = (formatMeta?.[kRevealJSPlugins] as Array<
830
string | RevealPluginBundle | RevealPlugin
831
> ||
832
[])
833
.flatMap(
834
(plugin) => {
835
return resolveRevealJSPlugin(
836
embeddedExtensions,
837
extensionDir,
838
plugin,
839
);
840
},
841
);
842
});
843
delete formats[kCommon];
844
845
// Alias the contributions
846
const shortcodes = ((contributes?.shortcodes || []) as string[]).map(
847
(shortcode) => {
848
return resolveShortcodePath(extensionDir, shortcode);
849
},
850
);
851
const filters = ((contributes?.filters || []) as QuartoFilter[]).map(
852
(filter) => {
853
return resolveFilterPath(extensionDir, filter);
854
},
855
);
856
const project = contributes?.project as Record<string, unknown> | undefined;
857
const metadata = contributes?.metadata as Record<string, unknown> | undefined;
858
859
// resolve metadata/project pre- and post-render scripts to their full path
860
for (const key of ["pre-render", "post-render", "brand"]) {
861
for (const object of [metadata, project]) {
862
if (!object?.project || typeof object.project !== "object") {
863
continue;
864
}
865
// object.project is truthy and typeof object.project is object
866
// so we can safely cast object.project to Record<string, unknown>
867
// the TypeScript checker doesn't appear to recognize this
868
const t = (object.project as Record<string, unknown>)[key];
869
if (t) {
870
const value = (Array.isArray(t) ? t : [t]) as string[];
871
const resolved = resolvePathGlobs(
872
extensionDir,
873
value as string[],
874
[],
875
);
876
if (resolved.include.length > 0) {
877
if (key === "brand") {
878
let projectDir = extensionDir, last;
879
do {
880
last = basename(projectDir);
881
projectDir = dirname(projectDir);
882
} while (projectDir && last !== "_extensions");
883
if (projectDir) {
884
(object.project as Record<string, unknown>)[key] = relative(
885
projectDir,
886
resolved.include[0],
887
);
888
}
889
} else {
890
(object.project as Record<string, unknown>)[key] = resolved.include;
891
}
892
}
893
}
894
}
895
}
896
const revealJSPlugins = ((contributes?.[kRevealJSPlugins] || []) as Array<
897
string | RevealPluginBundle | RevealPlugin
898
>).map((plugin) => {
899
return resolveRevealPlugin(extensionDir, plugin);
900
});
901
902
// Process engine contributions
903
const engines =
904
((contributes?.engines || []) as Array<string | ExternalEngine>).map(
905
(engine) => {
906
if (typeof engine === "string") {
907
return engine;
908
} else if (typeof engine === "object" && engine.path) {
909
// Convert relative path to absolute path
910
return {
911
...engine,
912
path: join(extensionDir, engine.path),
913
};
914
}
915
return engine;
916
},
917
);
918
919
// Create the extension data structure
920
const result = {
921
title,
922
author,
923
version,
924
quartoVersion,
925
id: extensionId,
926
path: extensionDir,
927
contributes: {
928
metadata,
929
shortcodes,
930
filters,
931
formats,
932
project: project ?? {},
933
[kRevealJSPlugins]: revealJSPlugins,
934
engines: engines.length > 0 ? engines : undefined,
935
},
936
};
937
validateExtension(result);
938
return result;
939
}
940
941
function resolveRevealJSPlugin(
942
embeddedExtensions: Extension[],
943
dir: string,
944
plugin: string | RevealPluginBundle | RevealPlugin,
945
) {
946
if (typeof plugin === "string") {
947
// First attempt to load this plugin from an embedded extension
948
const extensionId = toExtensionId(plugin);
949
const extensions = findExtensions(
950
embeddedExtensions,
951
extensionId,
952
"revealjs-plugins",
953
);
954
955
// If there are embedded extensions, return their plugins
956
if (extensions.length > 0) {
957
const plugins: Array<string | RevealPluginBundle | RevealPlugin> = [];
958
for (const plugin of extensions[0].contributes[kRevealJSPlugins] || []) {
959
plugins.push(plugin);
960
}
961
return plugins;
962
} else {
963
// There are no embedded extensions for this, validate the path
964
validateExtensionPath("revealjs-plugin", dir, plugin);
965
return resolveRevealPlugin(dir, plugin);
966
}
967
} else {
968
return plugin;
969
}
970
}
971
972
export function isPluginRaw(
973
plugin: RevealPluginBundle | RevealPluginInline,
974
): plugin is RevealPluginInline {
975
return (plugin as RevealPluginBundle).plugin === undefined;
976
}
977
978
function resolveRevealPlugin(
979
extensionDir: string,
980
plugin: string | RevealPluginBundle | RevealPluginInline,
981
): string | RevealPluginBundle | RevealPlugin {
982
// Filters are expected to be absolute
983
if (typeof plugin === "string") {
984
return join(extensionDir, plugin);
985
} else if (isPluginRaw(plugin)) {
986
return resolveRevealPluginInline(plugin, extensionDir);
987
} else {
988
plugin.plugin = join(extensionDir, plugin.plugin);
989
return plugin;
990
}
991
}
992
993
function resolveRevealPluginInline(
994
plugin: RevealPluginInline,
995
extensionDir: string,
996
): RevealPlugin {
997
if (!plugin.name) {
998
throw new Error(
999
`Invalid revealjs-plugin in ${extensionDir} - 'name' property is required.`,
1000
);
1001
}
1002
1003
// Resolve plugin raw into plugin
1004
const resolvedPlugin: RevealPlugin = {
1005
name: plugin.name,
1006
path: extensionDir,
1007
register: plugin.register,
1008
config: plugin.config,
1009
};
1010
if (plugin.script) {
1011
const pluginArr = Array.isArray(plugin.script)
1012
? plugin.script
1013
: [plugin.script];
1014
resolvedPlugin.script = pluginArr.map((plug) => {
1015
if (typeof plug === "string") {
1016
return {
1017
path: plug,
1018
} as RevealPluginScript;
1019
} else {
1020
return plug;
1021
}
1022
});
1023
}
1024
1025
if (plugin.stylesheet) {
1026
resolvedPlugin.stylesheet = Array.isArray(plugin.stylesheet)
1027
? plugin.stylesheet
1028
: [plugin.stylesheet];
1029
}
1030
return resolvedPlugin;
1031
}
1032
1033
// This will resolve a shortcode contributed by this extension
1034
// loading embedded extensions and replacing the extension name
1035
// with the contributed shortcode paths
1036
function resolveShortcode(
1037
embeddedExtensions: Extension[],
1038
dir: string,
1039
shortcode: string,
1040
) {
1041
// First attempt to load this shortcode from an embedded extension
1042
const extensionId = toExtensionId(shortcode);
1043
const extensions = findExtensions(
1044
embeddedExtensions,
1045
extensionId,
1046
"shortcodes",
1047
);
1048
1049
// If there are embedded extensions, return their shortcodes
1050
if (extensions.length > 0) {
1051
const shortcodes: string[] = [];
1052
for (const shortcode of extensions[0].contributes.shortcodes || []) {
1053
// Shortcodes are expected to be absolute
1054
shortcodes.push(resolveShortcodePath(extensions[0].path, shortcode));
1055
}
1056
return shortcodes;
1057
} else {
1058
// There are no embedded extensions for this, validate the path
1059
validateExtensionPath("shortcode", dir, shortcode);
1060
return resolveShortcodePath(dir, shortcode);
1061
}
1062
}
1063
1064
function resolveShortcodePath(
1065
extensionDir: string,
1066
shortcode: string,
1067
): string {
1068
if (isAbsolute(shortcode)) {
1069
return shortcode;
1070
} else {
1071
return join(extensionDir, shortcode);
1072
}
1073
}
1074
1075
// This will replace the given QuartoFilter with one more resolved filters,
1076
// loading embedded extensions (if referenced) and replacing the extension
1077
// name with any filters that the embedded extension provides.
1078
function resolveFilter(
1079
embeddedExtensions: Extension[],
1080
dir: string,
1081
filter: QuartoFilter,
1082
) {
1083
if (typeof filter === "string") {
1084
// First check for the sentinel quarto filter, and allow that through
1085
// if it is present
1086
if (filter === "quarto") {
1087
return filter;
1088
}
1089
1090
// First attempt to load this shortcode from an embedded extension
1091
const extensionId = toExtensionId(filter);
1092
const extensions = findExtensions(
1093
embeddedExtensions,
1094
extensionId,
1095
"filters",
1096
);
1097
if (extensions.length > 0) {
1098
const filters: QuartoFilter[] = [];
1099
for (const filter of extensions[0].contributes.filters || []) {
1100
filters.push(resolveFilterPath(extensions[0].path, filter));
1101
}
1102
return filters;
1103
} else {
1104
validateExtensionPath("filter", dir, filter);
1105
return resolveFilterPath(dir, filter);
1106
}
1107
} else {
1108
validateExtensionPath("filter", dir, filter.path);
1109
return resolveFilterPath(dir, filter);
1110
}
1111
}
1112
1113
function resolveFilterPath(
1114
extensionDir: string,
1115
filter: QuartoFilter,
1116
): QuartoFilter {
1117
// Filters are expected to be absolute
1118
if (typeof filter === "string") {
1119
if (isAbsolute(filter)) {
1120
return filter;
1121
} else {
1122
return join(extensionDir, filter);
1123
}
1124
} else {
1125
// deno-lint-ignore no-explicit-any
1126
const filterAt = ((filter as any).at) as string | undefined;
1127
const result: QuartoFilter = {
1128
type: filter.type,
1129
path: isAbsolute(filter.path)
1130
? filter.path
1131
: join(extensionDir, filter.path),
1132
};
1133
if (filterAt === undefined) {
1134
return result;
1135
} else {
1136
return {
1137
...result,
1138
at: filterAt,
1139
};
1140
}
1141
}
1142
}
1143
1144
// Validates that the path exists. For filters and short codes used in extensions,
1145
// either the item should resolve using an embedded extension, or the path
1146
// should exist. You cannot reference a non-existent file in an extension
1147
function validateExtensionPath(
1148
type: "filter" | "shortcode" | "revealjs-plugin",
1149
dir: string,
1150
path: string,
1151
) {
1152
const resolves = existsSync(join(dir, path));
1153
if (!resolves) {
1154
throw Error(
1155
`Failed to resolve referenced ${type} ${path} - path does not exist.\nIf you are attempting to use another extension within this extension, please install the extension using the 'quarto install --embedded' command.`,
1156
);
1157
}
1158
return resolves;
1159
}
1160
1161
// Parses string into extension Id
1162
// <organization>/<name>
1163
// <name>
1164
function toExtensionId(extension: string) {
1165
if (extension.indexOf("/") > -1) {
1166
const extParts = extension.split("/");
1167
// Names with organization have exactly 1 slash
1168
if (extParts.length === 2) {
1169
return {
1170
name: extParts[1],
1171
organization: extParts[0],
1172
};
1173
} else {
1174
return {
1175
name: extension,
1176
};
1177
}
1178
} else {
1179
return {
1180
name: extension,
1181
};
1182
}
1183
}
1184
1185
export const extensionFile = (dir: string) => {
1186
return ["_extension.yml", "_extension.yaml"]
1187
.map((file) => join(dir, file))
1188
.find(existsSync1);
1189
};
1190
1191