Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/project/project-shared.ts
6450 views
1
/*
2
* project-shared.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { existsSync, safeRemoveSync } from "../deno_ral/fs.ts";
8
import {
9
dirname,
10
isAbsolute,
11
join,
12
relative,
13
SEP_PATTERN,
14
} from "../deno_ral/path.ts";
15
import { kHtmlMathMethod } from "../config/constants.ts";
16
import { Format, Metadata } from "../config/types.ts";
17
import { mergeConfigs } from "../core/config.ts";
18
import { getFrontMatterSchema } from "../core/lib/yaml-schema/front-matter.ts";
19
20
import { normalizePath, pathWithForwardSlashes } from "../core/path.ts";
21
import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts";
22
import {
23
FileInclusion,
24
FileInformation,
25
kProjectOutputDir,
26
kProjectType,
27
ProjectConfig,
28
ProjectContext,
29
} from "./types.ts";
30
import { projectType } from "./types/project-types.ts";
31
import { ProjectType } from "./types/types.ts";
32
import { kWebsite } from "./types/website/website-constants.ts";
33
import { existsSync1 } from "../core/file.ts";
34
import { kManuscriptType } from "./types/manuscript/manuscript-types.ts";
35
import { expandIncludes } from "../core/handlers/base.ts";
36
import { MappedString, mappedStringFromFile } from "../core/mapped-text.ts";
37
import { createTempContext } from "../core/temp.ts";
38
import { RenderContext, RenderFlags } from "../command/render/types.ts";
39
import { LanguageCellHandlerOptions } from "../core/handlers/types.ts";
40
import { ExecutionEngineInstance } from "../execute/types.ts";
41
import { InspectedMdCell } from "../inspect/inspect-types.ts";
42
import { breakQuartoMd, QuartoMdCell } from "../core/lib/break-quarto-md.ts";
43
import { partitionCellOptionsText } from "../core/lib/partition-cell-options.ts";
44
import { parse } from "../core/yaml.ts";
45
import { mappedIndexToLineCol } from "../core/lib/mapped-text.ts";
46
import { normalizeNewlines } from "../core/lib/text.ts";
47
import { DirectiveCell } from "../core/lib/break-quarto-md-types.ts";
48
import { QuartoJSONSchema, readYamlFromMarkdown } from "../core/yaml.ts";
49
import { refSchema } from "../core/lib/yaml-schema/common.ts";
50
import { Zod } from "../resources/types/zod/schema-types.ts";
51
import {
52
Brand,
53
LightDarkBrand,
54
LightDarkBrandDarkFlag,
55
splitUnifiedBrand,
56
} from "../core/brand/brand.ts";
57
import { assert } from "testing/asserts";
58
import { Cloneable, safeCloneDeep } from "../core/safe-clone-deep.ts";
59
60
export function projectExcludeDirs(context: ProjectContext): string[] {
61
const outputDir = projectOutputDir(context);
62
if (outputDir) {
63
return [outputDir];
64
} else {
65
return [];
66
}
67
}
68
69
export function projectFormatOutputDir(
70
format: Format,
71
context: ProjectContext,
72
type: ProjectType,
73
) {
74
const projOutputDir = projectOutputDir(context);
75
if (type.formatOutputDirectory) {
76
const formatOutputDir = type.formatOutputDirectory(format);
77
if (formatOutputDir) {
78
return join(projOutputDir, formatOutputDir);
79
} else {
80
return projOutputDir;
81
}
82
} else {
83
return projOutputDir;
84
}
85
}
86
87
export function projectOutputDir(context: ProjectContext): string {
88
let outputDir = context.config?.project[kProjectOutputDir];
89
if (outputDir) {
90
if (!isAbsolute(outputDir)) {
91
outputDir = join(context.dir, outputDir);
92
}
93
} else {
94
outputDir = context.dir;
95
}
96
if (existsSync(outputDir!)) {
97
return normalizePath(outputDir!);
98
} else {
99
return outputDir!;
100
}
101
}
102
103
export function hasProjectOutputDir(context: ProjectContext): boolean {
104
return !!context.config?.project[kProjectOutputDir];
105
}
106
107
export function isProjectInputFile(path: string, context: ProjectContext) {
108
if (existsSync(path)) {
109
const renderPath = normalizePath(path);
110
return context.files.input.map((file) => normalizePath(file)).includes(
111
renderPath,
112
);
113
} else {
114
return false;
115
}
116
}
117
118
export function projectConfigFile(dir: string): string | undefined {
119
return ["_quarto.yml", "_quarto.yaml"]
120
.map((file) => join(dir, file))
121
.find(existsSync1);
122
}
123
124
export function projectVarsFile(dir: string): string | undefined {
125
return ["_variables.yml", "_variables.yaml"]
126
.map((file) => join(dir, file))
127
.find(existsSync1);
128
}
129
130
export function projectOffset(context: ProjectContext, input: string) {
131
const projDir = normalizePath(context.dir);
132
const inputDir = normalizePath(dirname(input));
133
const offset = relative(inputDir, projDir) || ".";
134
return pathWithForwardSlashes(offset);
135
}
136
137
export function toInputRelativePaths(
138
type: ProjectType,
139
baseDir: string,
140
inputDir: string,
141
collection: Array<unknown> | Record<string, unknown>,
142
ignoreResources?: string[],
143
) {
144
const existsCache = new Map<string, string>();
145
const resourceIgnoreFields = ignoreResources ||
146
ignoreFieldsForProjectType(type) || [];
147
const offset = relative(inputDir, baseDir);
148
149
const fixup = (value: string) => {
150
// if this is a valid file, then transform it to be relative to the input path
151
if (!existsCache.has(value)) {
152
const projectPath = join(baseDir, value);
153
try {
154
if (existsSync(projectPath)) {
155
existsCache.set(
156
value,
157
pathWithForwardSlashes(join(offset!, value)),
158
);
159
} else {
160
existsCache.set(value, value);
161
}
162
} catch {
163
existsCache.set(value, value);
164
}
165
}
166
return existsCache.get(value);
167
};
168
169
const inner = (
170
collection: Array<unknown> | Record<string, unknown>,
171
parentKey?: unknown,
172
) => {
173
if (Array.isArray(collection)) {
174
for (let index = 0; index < collection.length; ++index) {
175
const value = collection[index];
176
if (Array.isArray(value) || value instanceof Object) {
177
inner(value as Array<unknown>);
178
} else if (typeof value === "string") {
179
if (value.length > 0 && !isAbsolute(value)) {
180
collection[index] = fixup(value);
181
}
182
}
183
}
184
} else {
185
for (const index of Object.keys(collection)) {
186
const value = collection[index];
187
if (
188
(parentKey === kHtmlMathMethod && index === "method") ||
189
resourceIgnoreFields!.includes(index)
190
) {
191
// don't fixup html-math-method
192
} else if (Array.isArray(value) || value instanceof Object) {
193
// deno-lint-ignore no-explicit-any
194
inner(value as any, index);
195
} else if (typeof value === "string") {
196
if (value.length > 0 && !isAbsolute(value)) {
197
collection[index] = fixup(value);
198
}
199
}
200
}
201
}
202
};
203
204
inner(collection);
205
return collection;
206
}
207
208
export function ignoreFieldsForProjectType(type?: ProjectType) {
209
const resourceIgnoreFields = type
210
? ["project"].concat(
211
type.resourceIgnoreFields ? type.resourceIgnoreFields() : [],
212
)
213
: [] as string[];
214
return resourceIgnoreFields;
215
}
216
217
export function projectIsWebsite(context?: ProjectContext): boolean {
218
if (context) {
219
const projType = projectType(context.config?.project?.[kProjectType]);
220
return projectTypeIsWebsite(projType);
221
} else {
222
return false;
223
}
224
}
225
226
export function projectIsManuscript(context?: ProjectContext): boolean {
227
if (context) {
228
const projType = projectType(context.config?.project?.[kProjectType]);
229
return projType.type === kManuscriptType;
230
} else {
231
return false;
232
}
233
}
234
235
export function projectPreviewServe(context?: ProjectContext) {
236
return context?.config?.project?.preview?.serve;
237
}
238
239
export function projectIsServeable(context?: ProjectContext): boolean {
240
return projectIsWebsite(context) || projectIsManuscript(context) ||
241
!!projectPreviewServe(context);
242
}
243
244
export function projectTypeIsWebsite(projType: ProjectType): boolean {
245
return projType.type === kWebsite || projType.inheritsType === kWebsite;
246
}
247
248
export function projectIsBook(context?: ProjectContext): boolean {
249
if (context) {
250
const projType = projectType(context.config?.project?.[kProjectType]);
251
return projType.type === "book";
252
} else {
253
return false;
254
}
255
}
256
257
export function deleteProjectMetadata(metadata: Metadata) {
258
// see if the active project type wants to filter the config printed
259
const projType = projectType(
260
(metadata as ProjectConfig).project?.[kProjectType],
261
);
262
if (projType.metadataFields) {
263
for (const field of projType.metadataFields().concat("project")) {
264
if (typeof field === "string") {
265
delete metadata[field];
266
} else {
267
for (const key of Object.keys(metadata)) {
268
if (field.test(key)) {
269
delete metadata[key];
270
}
271
}
272
}
273
}
274
}
275
276
// remove project config
277
delete metadata.project;
278
}
279
280
export function normalizeFormatYaml(yamlFormat: unknown) {
281
if (yamlFormat) {
282
if (typeof yamlFormat === "string") {
283
yamlFormat = {
284
[yamlFormat]: {},
285
};
286
} else if (typeof yamlFormat === "object") {
287
const formats = Object.keys(yamlFormat);
288
for (const format of formats) {
289
if (
290
(yamlFormat as Record<string, unknown>)[format] === "default"
291
) {
292
(yamlFormat as Record<string, unknown>)[format] = {};
293
}
294
}
295
}
296
}
297
return (yamlFormat || {}) as Record<string, unknown>;
298
}
299
export async function directoryMetadataForInputFile(
300
project: ProjectContext,
301
inputDir: string,
302
) {
303
const projectDir = project.dir;
304
// Finds a metadata file in a directory
305
const metadataFile = (dir: string) => {
306
return ["_metadata.yml", "_metadata.yaml"]
307
.map((file) => join(dir, file))
308
.find(existsSync1);
309
};
310
311
// The path from the project dir to the input dir
312
const relativePath = relative(projectDir, inputDir);
313
const dirs = relativePath.split(SEP_PATTERN);
314
315
// The config we'll ultimately return
316
let config = {};
317
318
// Walk through each directory (starting from the project and
319
// walking deeper to the input)
320
let currentDir = projectDir;
321
const frontMatterSchema = await getFrontMatterSchema();
322
for (let i = 0; i < dirs.length; i++) {
323
const dir = dirs[i];
324
currentDir = join(currentDir, dir);
325
const file = metadataFile(currentDir);
326
if (file) {
327
// There is a metadata file, read it and merge it
328
// Note that we need to convert paths that are relative
329
// to the metadata file to be relative to input
330
const errMsg = "Directory metadata validation failed for " + file + ".";
331
const yaml = ((await readAndValidateYamlFromFile(
332
file,
333
frontMatterSchema,
334
errMsg,
335
)) || {}) as Record<string, unknown>;
336
337
// resolve format into expected structure
338
if (yaml.format) {
339
yaml.format = normalizeFormatYaml(yaml.format);
340
}
341
342
config = mergeConfigs(
343
config,
344
toInputRelativePaths(
345
projectType(project?.config?.project?.[kProjectType]),
346
currentDir,
347
inputDir,
348
yaml as Record<string, unknown>,
349
),
350
);
351
}
352
}
353
354
return config;
355
}
356
357
const mdForFile = async (
358
_project: ProjectContext,
359
engine: ExecutionEngineInstance | undefined,
360
file: string,
361
): Promise<MappedString> => {
362
if (engine) {
363
return await engine.markdownForFile(file);
364
} else {
365
// Last resort, just read the file
366
return Promise.resolve(mappedStringFromFile(file));
367
}
368
};
369
370
export async function projectResolveCodeCellsForFile(
371
project: ProjectContext,
372
engine: ExecutionEngineInstance | undefined,
373
file: string,
374
markdown?: MappedString,
375
force?: boolean,
376
): Promise<InspectedMdCell[]> {
377
const cache = ensureFileInformationCache(project, file);
378
if (!force && cache.codeCells) {
379
return cache.codeCells || [];
380
}
381
if (!markdown) {
382
markdown = await mdForFile(project, engine, file);
383
}
384
385
const result: InspectedMdCell[] = [];
386
const fileStack: string[] = [];
387
388
const inner = async (file: string, cells: QuartoMdCell[]) => {
389
if (fileStack.includes(file)) {
390
throw new Error(
391
"Circular include detected:\n " + fileStack.join(" ->\n "),
392
);
393
}
394
fileStack.push(file);
395
for (const cell of cells) {
396
if (typeof cell.cell_type === "string") {
397
continue;
398
}
399
if (cell.cell_type.language === "_directive") {
400
const directiveCell = cell.cell_type as DirectiveCell;
401
if (directiveCell.name !== "include") {
402
continue;
403
}
404
const arg = directiveCell.shortcode.params[0];
405
const paths = arg.startsWith("/")
406
? [project.dir, arg]
407
: [project.dir, relative(project.dir, dirname(file)), arg];
408
const innerFile = join(...paths);
409
await inner(
410
innerFile,
411
(await breakQuartoMd(
412
await mdForFile(project, engine, innerFile),
413
)).cells,
414
);
415
}
416
if (
417
cell.cell_type.language !== "_directive"
418
) {
419
const cellOptions = partitionCellOptionsText(
420
cell.cell_type.language,
421
cell.sourceWithYaml ?? cell.source,
422
);
423
const metadata = cellOptions.yaml
424
? parse(cellOptions.yaml.value, {
425
schema: QuartoJSONSchema,
426
}) as Record<string, unknown>
427
: {};
428
const lineLocator = mappedIndexToLineCol(cell.sourceVerbatim);
429
result.push({
430
start: lineLocator(0).line,
431
end: lineLocator(cell.sourceVerbatim.value.length - 1).line,
432
file: file,
433
source: normalizeNewlines(cell.source.value),
434
language: cell.cell_type.language,
435
metadata,
436
});
437
}
438
}
439
fileStack.pop();
440
};
441
await inner(file, (await breakQuartoMd(markdown)).cells);
442
cache.codeCells = result;
443
return result;
444
}
445
446
export async function projectFileMetadata(
447
project: ProjectContext,
448
file: string,
449
force?: boolean,
450
): Promise<Metadata> {
451
const cache = ensureFileInformationCache(project, file);
452
if (!force && cache.metadata) {
453
return cache.metadata;
454
}
455
const { engine } = await project.fileExecutionEngineAndTarget(file);
456
const markdown = await mdForFile(project, engine, file);
457
const metadata = readYamlFromMarkdown(markdown.value);
458
cache.metadata = metadata;
459
return metadata;
460
}
461
462
export async function projectResolveFullMarkdownForFile(
463
project: ProjectContext,
464
engine: ExecutionEngineInstance | undefined,
465
file: string,
466
markdown?: MappedString,
467
force?: boolean,
468
): Promise<MappedString> {
469
const cache = ensureFileInformationCache(project, file);
470
if (!force && cache.fullMarkdown) {
471
return cache.fullMarkdown;
472
}
473
474
const temp = createTempContext();
475
476
if (!markdown) {
477
markdown = await mdForFile(project, engine, file);
478
}
479
480
const options: LanguageCellHandlerOptions = {
481
name: "",
482
temp,
483
stage: "pre-engine",
484
format: undefined as unknown as Format,
485
markdown,
486
context: {
487
project,
488
target: {
489
source: file,
490
},
491
} as unknown as RenderContext,
492
flags: {} as RenderFlags,
493
};
494
try {
495
const result = await expandIncludes(markdown, options, file);
496
cache.fullMarkdown = result;
497
cache.includeMap = options.state?.include.includes as FileInclusion[];
498
return result;
499
} finally {
500
temp.cleanup();
501
}
502
}
503
504
export const ensureFileInformationCache = (
505
project: ProjectContext,
506
file: string,
507
) => {
508
if (!project.fileInformationCache) {
509
project.fileInformationCache = new FileInformationCacheMap();
510
}
511
assert(
512
project.fileInformationCache instanceof Map,
513
JSON.stringify(project.fileInformationCache),
514
);
515
if (!project.fileInformationCache.has(file)) {
516
project.fileInformationCache.set(file, {} as FileInformation);
517
}
518
return project.fileInformationCache.get(file)!;
519
};
520
521
export async function projectResolveBrand(
522
project: ProjectContext,
523
fileName?: string,
524
): Promise<LightDarkBrandDarkFlag | undefined> {
525
async function loadSingleBrand(brandPath: string): Promise<Brand> {
526
const brand = await readAndValidateYamlFromFile(
527
brandPath,
528
refSchema("brand-single", "Format-independent brand configuration."),
529
"Brand validation failed for " + brandPath + ".",
530
);
531
return new Brand(brand, dirname(brandPath), project.dir);
532
}
533
async function loadUnifiedBrand(
534
brandPath: string,
535
): Promise<LightDarkBrandDarkFlag> {
536
const brand = await readAndValidateYamlFromFile(
537
brandPath,
538
refSchema("brand-unified", "Format-independent brand configuration."),
539
"Brand validation failed for " + brandPath + ".",
540
);
541
return splitUnifiedBrand(brand, dirname(brandPath), project.dir);
542
}
543
function resolveBrandPath(
544
brandPath: string,
545
dir: string = dirname(fileName!),
546
): string {
547
let resolved: string = "";
548
if (brandPath.startsWith("/")) {
549
resolved = join(project.dir, brandPath);
550
} else {
551
resolved = join(dir, brandPath);
552
}
553
return resolved;
554
}
555
if (fileName === undefined) {
556
if (project.brandCache) {
557
return project.brandCache.brand;
558
}
559
project.brandCache = {};
560
let fileNames = [
561
"_brand.yml",
562
"_brand.yaml",
563
"_brand/_brand.yml",
564
"_brand/_brand.yaml",
565
].map((file) => join(project.dir, file));
566
const brand = (project?.config?.brand ??
567
project?.config?.project.brand) as
568
| boolean
569
| string
570
| {
571
light?: string;
572
dark?: string;
573
};
574
if (brand === false) {
575
project.brandCache.brand = undefined;
576
return project.brandCache.brand;
577
}
578
if (
579
typeof brand === "object" && brand &&
580
("light" in brand || "dark" in brand)
581
) {
582
project.brandCache.brand = {
583
light: brand.light
584
? await loadSingleBrand(resolveBrandPath(brand.light, project.dir))
585
: undefined,
586
dark: brand.dark
587
? await loadSingleBrand(resolveBrandPath(brand.dark, project.dir))
588
: undefined,
589
enablesDarkMode: !!brand.dark,
590
};
591
return project.brandCache.brand;
592
}
593
if (typeof brand === "string") {
594
fileNames = [join(project.dir, brand)];
595
}
596
597
for (const brandPath of fileNames) {
598
if (!existsSync(brandPath)) {
599
continue;
600
}
601
project.brandCache.brand = await loadUnifiedBrand(brandPath);
602
}
603
return project.brandCache.brand;
604
} else {
605
const metadata = await project.fileMetadata(fileName);
606
if (metadata.brand === undefined) {
607
return project.resolveBrand();
608
}
609
const brand = Zod.BrandPathBoolLightDark.parse(metadata.brand);
610
if (brand === false) {
611
return undefined;
612
}
613
if (brand === true) {
614
return project.resolveBrand();
615
}
616
const fileInformation = ensureFileInformationCache(project, fileName);
617
if (fileInformation.brand) {
618
return fileInformation.brand;
619
}
620
if (typeof brand === "string") {
621
fileInformation.brand = await loadUnifiedBrand(resolveBrandPath(brand));
622
return fileInformation.brand;
623
} else {
624
assert(typeof brand === "object");
625
if ("light" in brand || "dark" in brand) {
626
let light, dark;
627
if (typeof brand.light === "string") {
628
light = await loadSingleBrand(resolveBrandPath(brand.light));
629
} else if (brand.light) {
630
light = new Brand(
631
brand.light,
632
dirname(fileName),
633
project.dir,
634
);
635
}
636
if (typeof brand.dark === "string") {
637
dark = await loadSingleBrand(resolveBrandPath(brand.dark));
638
} else if (brand.dark) {
639
dark = new Brand(
640
brand.dark,
641
dirname(fileName),
642
project.dir,
643
);
644
}
645
fileInformation.brand = { light, dark, enablesDarkMode: !!dark };
646
} else {
647
fileInformation.brand = splitUnifiedBrand(
648
brand,
649
dirname(fileName),
650
project.dir,
651
);
652
}
653
return fileInformation.brand;
654
}
655
}
656
}
657
658
// A Map that normalizes path keys for cross-platform consistency.
659
// All path operations normalize keys (forward slashes, lowercase on Windows).
660
// Implements Cloneable but shares state intentionally - in preview mode,
661
// the project context is reused across renders and cache state must persist.
662
export class FileInformationCacheMap extends Map<string, FileInformation>
663
implements Cloneable<Map<string, FileInformation>> {
664
override get(key: string): FileInformation | undefined {
665
return super.get(normalizePath(key));
666
}
667
668
override has(key: string): boolean {
669
return super.has(normalizePath(key));
670
}
671
672
override set(key: string, value: FileInformation): this {
673
return super.set(normalizePath(key), value);
674
}
675
676
override delete(key: string): boolean {
677
return super.delete(normalizePath(key));
678
}
679
680
// Note: Iterator methods (keys(), entries(), forEach(), [Symbol.iterator])
681
// return normalized keys as stored. Code iterating over the cache sees
682
// normalized paths, which is consistent with how keys are stored.
683
684
// Returns this instance (shared reference) rather than a copy.
685
// This is intentional: in preview mode, project context is cloned for
686
// each render but the cache must be shared so invalidations persist.
687
clone(): Map<string, FileInformation> {
688
return this;
689
}
690
}
691
692
export function cleanupFileInformationCache(project: ProjectContext) {
693
project.fileInformationCache.forEach((entry) => {
694
if (entry?.target?.data) {
695
const data = entry.target.data as {
696
transient?: boolean;
697
};
698
if (data.transient && entry.target?.input) {
699
safeRemoveSync(entry.target?.input);
700
}
701
}
702
});
703
}
704
705
export async function withProjectCleanup<T>(
706
project: ProjectContext,
707
fn: (project: ProjectContext) => Promise<T>,
708
): Promise<T> {
709
try {
710
return await fn(project);
711
} finally {
712
project.cleanup();
713
}
714
}
715
716