Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/project/project-context.ts
6458 views
1
/*
2
* project-context.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import {
8
dirname,
9
globToRegExp,
10
isAbsolute,
11
join,
12
relative,
13
resolve,
14
SEP,
15
} from "../deno_ral/path.ts";
16
17
import { existsSync, walk, walkSync } from "../deno_ral/fs.ts";
18
import * as ld from "../core/lodash.ts";
19
20
import { ProjectType } from "./types/types.ts";
21
import { Format, Metadata, PandocFlags } from "../config/types.ts";
22
import {
23
kProjectLibDir,
24
kProjectOutputDir,
25
kProjectPostRender,
26
kProjectPreRender,
27
kProjectRender,
28
kProjectType,
29
ProjectConfig,
30
ProjectContext,
31
} from "./types.ts";
32
33
import { isYamlPath, readYaml } from "../core/yaml.ts";
34
import { mergeConfigs } from "../core/config.ts";
35
import {
36
ensureTrailingSlash,
37
kSkipHidden,
38
normalizePath,
39
pathWithForwardSlashes,
40
safeExistsSync,
41
} from "../core/path.ts";
42
43
import { includedMetadata, mergeProjectMetadata } from "../config/metadata.ts";
44
import {
45
kHtmlMathMethod,
46
kLanguageDefaults,
47
kMetadataFile,
48
kMetadataFiles,
49
kMetadataFormat,
50
kQuartoVarsKey,
51
} from "../config/constants.ts";
52
53
import { projectType, projectTypes } from "./types/project-types.ts";
54
55
import { resolvePathGlobs } from "../core/path.ts";
56
import {
57
readLanguageTranslations,
58
resolveLanguageMetadata,
59
} from "../core/language.ts";
60
61
import {
62
engineIgnoreDirs,
63
executionEngineIntermediateFiles,
64
fileExecutionEngine,
65
fileExecutionEngineAndTarget,
66
projectIgnoreGlobs,
67
resolveEngines,
68
} from "../execute/engine.ts";
69
import { ExecutionEngineInstance, kMarkdownEngine } from "../execute/types.ts";
70
71
import { projectResourceFiles } from "./project-resources.ts";
72
73
import {
74
cleanupFileInformationCache,
75
FileInformationCacheMap,
76
ignoreFieldsForProjectType,
77
normalizeFormatYaml,
78
projectConfigFile,
79
projectFileMetadata,
80
projectResolveBrand,
81
projectResolveFullMarkdownForFile,
82
projectVarsFile,
83
} from "./project-shared.ts";
84
import { RenderOptions, RenderServices } from "../command/render/types.ts";
85
import { kWebsite } from "./types/website/website-constants.ts";
86
87
import { readAndValidateYamlFromFile } from "../core/schema/validated-yaml.ts";
88
89
import { getProjectConfigSchema } from "../core/lib/yaml-schema/project-config.ts";
90
import { kDefaultProjectFileContents } from "./types/project-default.ts";
91
import {
92
createExtensionContext,
93
filterExtensions,
94
} from "../extension/extension.ts";
95
import { initializeProfileConfig } from "./project-profile.ts";
96
import { dotenvSetVariables } from "../quarto-core/dotenv.ts";
97
import { ConcreteSchema } from "../core/lib/yaml-schema/types.ts";
98
import { ExtensionContext } from "../extension/types.ts";
99
import { asArray } from "../core/array.ts";
100
import { renderFormats } from "../command/render/render-contexts.ts";
101
import { computeProjectEnvironment } from "./project-environment.ts";
102
import { ProjectEnvironment } from "./project-environment-types.ts";
103
import { NotebookContext } from "../render/notebook/notebook-types.ts";
104
import { MappedString } from "../core/mapped-text.ts";
105
import { makeTimedFunctionAsync } from "../core/performance/function-times.ts";
106
import { createProjectCache } from "../core/cache/cache.ts";
107
import { createTempContext } from "../core/temp.ts";
108
109
import { onCleanup } from "../core/cleanup.ts";
110
import { Zod } from "../resources/types/zod/schema-types.ts";
111
import { ExternalEngine } from "../resources/types/schema-types.ts";
112
113
export const mergeExtensionMetadata = async (
114
context: ProjectContext,
115
pOptions: RenderOptions,
116
) => {
117
// this will mutate context.config.project to merge
118
// in any project metadata from extensions
119
if (context.config) {
120
const extensions = await pOptions.services.extension.extensions(
121
undefined,
122
context.config,
123
context.dir,
124
{ builtIn: false },
125
);
126
// Handle project metadata extensions
127
const projectMetadata = extensions.filter((extension) =>
128
extension.contributes.metadata?.project
129
).map((extension) => {
130
return Zod.ProjectConfig.parse(extension.contributes.metadata!.project);
131
});
132
context.config.project = mergeProjectMetadata(
133
context.config.project,
134
...projectMetadata,
135
);
136
}
137
};
138
139
export async function projectContext(
140
path: string,
141
notebookContext: NotebookContext,
142
renderOptions?: RenderOptions,
143
force = false,
144
): Promise<ProjectContext | undefined> {
145
const flags = renderOptions?.flags;
146
let dir = normalizePath(
147
Deno.statSync(path).isDirectory ? path : dirname(path),
148
);
149
const originalDir = dir;
150
151
// create an extension context if one doesn't exist
152
const extensionContext = renderOptions?.services.extension ||
153
createExtensionContext();
154
155
// first pass uses the config file resolve
156
const configSchema = await getProjectConfigSchema();
157
const configResolvers = [
158
quartoYamlProjectConfigResolver(configSchema),
159
await projectExtensionsConfigResolver(extensionContext, dir),
160
];
161
162
// Compute this on demand and only a single time per
163
// project context
164
let cachedEnv: ProjectEnvironment | undefined = undefined;
165
const environment = async (
166
project: ProjectContext,
167
) => {
168
if (cachedEnv) {
169
return Promise.resolve(cachedEnv);
170
} else {
171
cachedEnv = await computeProjectEnvironment(notebookContext, project);
172
return cachedEnv;
173
}
174
};
175
176
const returnResult = async (
177
context: ProjectContext,
178
) => {
179
if (renderOptions) {
180
await mergeExtensionMetadata(context, renderOptions);
181
}
182
onCleanup(context.cleanup);
183
return context;
184
};
185
186
while (true) {
187
// use the current resolver
188
const resolver = configResolvers[0];
189
const resolved = await resolver(dir);
190
if (resolved) {
191
let projectConfig = resolved.config;
192
const configFiles = resolved.files;
193
194
// migrate any legacy config
195
projectConfig = migrateProjectConfig(projectConfig);
196
197
// Look for project extension and load it
198
const projType = projectConfig.project[kProjectType];
199
if (projType && !(projectTypes().includes(projType))) {
200
projectConfig = await resolveProjectExtension(
201
extensionContext,
202
projType,
203
projectConfig,
204
dir,
205
);
206
207
// resolve includes
208
const configSchema = await getProjectConfigSchema();
209
const includedMeta = await includedMetadata(
210
dir,
211
projectConfig,
212
configSchema,
213
);
214
const metadata = includedMeta.metadata;
215
projectConfig = mergeProjectMetadata(projectConfig, metadata);
216
}
217
218
// Process engine extensions
219
if (extensionContext) {
220
projectConfig = await resolveEngineExtensions(
221
extensionContext,
222
projectConfig,
223
dir,
224
);
225
}
226
227
// collect then merge configuration profiles
228
const result = await initializeProfileConfig(
229
dir,
230
projectConfig,
231
configSchema,
232
);
233
projectConfig = result.config;
234
configFiles.push(...result.files);
235
236
// process dotenv files
237
const dotenvFiles = await dotenvSetVariables(dir);
238
configFiles.push(...dotenvFiles);
239
240
// read vars and merge into the project
241
const varsFile = projectVarsFile(dir);
242
if (varsFile) {
243
configFiles.push(varsFile);
244
const vars = readYaml(varsFile) as Metadata;
245
projectConfig[kQuartoVarsKey] = mergeConfigs(
246
projectConfig[kQuartoVarsKey] || {},
247
vars,
248
);
249
}
250
251
// resolve translations
252
const translationFiles = await resolveLanguageTranslations(
253
projectConfig,
254
dir,
255
);
256
configFiles.push(...translationFiles);
257
258
// inject format if specified in --to
259
if (flags?.to && flags?.to !== "all" && flags?.to !== "default") {
260
const projectFormats = normalizeFormatYaml(
261
projectConfig[kMetadataFormat],
262
);
263
const toFormat = projectFormats[flags?.to] || {};
264
delete projectFormats[flags?.to];
265
const formats = {
266
[flags?.to]: toFormat,
267
};
268
Object.keys(projectFormats).forEach((format) => {
269
formats[format] = projectFormats[format] as Record<never, never>;
270
});
271
projectConfig[kMetadataFormat] = formats;
272
}
273
274
if (projectConfig?.project) {
275
// provide output-dir from command line if specfified
276
if (flags?.outputDir) {
277
projectConfig.project[kProjectOutputDir] = flags.outputDir;
278
}
279
280
// convert pre and post render to array
281
if (typeof (projectConfig.project[kProjectPreRender]) === "string") {
282
projectConfig.project[kProjectPreRender] = [
283
projectConfig.project[kProjectPreRender] as unknown as string,
284
];
285
}
286
if (typeof (projectConfig.project[kProjectPostRender]) === "string") {
287
projectConfig.project[kProjectPostRender] = [
288
projectConfig.project[kProjectPostRender] as unknown as string,
289
];
290
}
291
292
// get project config and type-specific defaults for libDir and outputDir
293
const type = projectType(projectConfig.project?.[kProjectType]);
294
if (
295
projectConfig.project[kProjectLibDir] === undefined && type.libDir
296
) {
297
projectConfig.project[kProjectLibDir] = type.libDir;
298
}
299
if (!projectConfig.project[kProjectOutputDir] && type.outputDir) {
300
projectConfig.project[kProjectOutputDir] = type.outputDir;
301
}
302
303
// if the output-dir resolves to the project directory, that's equivalent to
304
// no output dir so make that conversion now (this allows code downstream to
305
// just check for no output dir rather than checking for ".", "./", etc.)
306
// Fixes issue #13892: output-dir: ./ would delete the entire project
307
const outputDir = projectConfig.project[kProjectOutputDir];
308
if (outputDir) {
309
const resolvedOutputDir = resolve(dir, outputDir);
310
const resolvedDir = resolve(dir);
311
if (resolvedOutputDir === resolvedDir) {
312
delete projectConfig.project[kProjectOutputDir];
313
}
314
}
315
316
// if the output-dir is absolute then make it project dir relative
317
const projOutputDir = projectConfig.project[kProjectOutputDir];
318
if (projOutputDir && isAbsolute(projOutputDir)) {
319
projectConfig.project[kProjectOutputDir] = relative(
320
dir,
321
projOutputDir,
322
);
323
}
324
325
const temp = createTempContext({
326
dir: join(dir, ".quarto"),
327
prefix: "quarto-session-temp",
328
});
329
const fileInformationCache = new FileInformationCacheMap();
330
const result: ProjectContext = {
331
clone: () => result,
332
resolveBrand: async (fileName?: string) =>
333
projectResolveBrand(result, fileName),
334
resolveFullMarkdownForFile: (
335
engine: ExecutionEngineInstance | undefined,
336
file: string,
337
markdown?: MappedString,
338
force?: boolean,
339
) => {
340
return projectResolveFullMarkdownForFile(
341
result,
342
engine,
343
file,
344
markdown,
345
force,
346
);
347
},
348
dir,
349
engines: [],
350
fileInformationCache,
351
files: {
352
input: [],
353
},
354
config: projectConfig,
355
// this is a relatively ugly hack to avoid a circular import chain
356
// that causes a deno bundler bug;
357
renderFormats,
358
environment: () => environment(result),
359
notebookContext,
360
fileExecutionEngineAndTarget: (
361
file: string,
362
) => {
363
return fileExecutionEngineAndTarget(
364
file,
365
flags,
366
result,
367
);
368
},
369
fileMetadata: async (file: string, force?: boolean) => {
370
return projectFileMetadata(result, file, force);
371
},
372
isSingleFile: false,
373
previewServer: renderOptions?.previewServer,
374
diskCache: await createProjectCache(join(dir, ".quarto")),
375
temp,
376
cleanup: () => {
377
cleanupFileInformationCache(result);
378
result.diskCache.close();
379
temp.cleanup();
380
},
381
};
382
383
// see if the project [kProjectType] wants to filter the project config
384
if (type.config) {
385
result.config = await type.config(
386
result,
387
projectConfig,
388
flags,
389
);
390
}
391
const { files, engines } = await projectInputFiles(
392
result,
393
projectConfig,
394
);
395
// if we are attemping to get the projectConext for a file and the
396
// file isn't in list of input files then return a single-file project
397
const fullPath = normalizePath(path);
398
if (Deno.statSync(fullPath).isFile && !files.includes(fullPath)) {
399
return undefined;
400
}
401
402
if (type.formatExtras) {
403
result.formatExtras = async (
404
source: string,
405
flags: PandocFlags,
406
format: Format,
407
services: RenderServices,
408
) => type.formatExtras!(result, source, flags, format, services);
409
}
410
result.engines = engines;
411
result.files = {
412
input: files,
413
resources: projectResourceFiles(dir, projectConfig),
414
config: configFiles,
415
configResources: projectConfigResources(dir, projectConfig, type),
416
};
417
return await returnResult(result);
418
} else {
419
const temp = createTempContext({
420
dir: join(dir, ".quarto"),
421
prefix: "quarto-session-temp",
422
});
423
const fileInformationCache = new FileInformationCacheMap();
424
const result: ProjectContext = {
425
clone: () => result,
426
resolveBrand: async (fileName?: string) =>
427
projectResolveBrand(result, fileName),
428
resolveFullMarkdownForFile: (
429
engine: ExecutionEngineInstance | undefined,
430
file: string,
431
markdown?: MappedString,
432
force?: boolean,
433
) => {
434
return projectResolveFullMarkdownForFile(
435
result,
436
engine,
437
file,
438
markdown,
439
force,
440
);
441
},
442
dir,
443
config: projectConfig,
444
engines: [],
445
fileInformationCache,
446
files: {
447
input: [],
448
},
449
renderFormats,
450
environment: () => environment(result),
451
fileExecutionEngineAndTarget: (
452
file: string,
453
) => {
454
return fileExecutionEngineAndTarget(
455
file,
456
flags,
457
result,
458
);
459
},
460
fileMetadata: async (file: string, force?: boolean) => {
461
return projectFileMetadata(result, file, force);
462
},
463
notebookContext,
464
isSingleFile: false,
465
previewServer: renderOptions?.previewServer,
466
diskCache: await createProjectCache(join(dir, ".quarto")),
467
temp,
468
cleanup: () => {
469
cleanupFileInformationCache(result);
470
result.diskCache.close();
471
temp.cleanup();
472
},
473
};
474
const { files, engines } = await projectInputFiles(
475
result,
476
projectConfig,
477
);
478
result.engines = engines;
479
result.files = {
480
input: files,
481
resources: projectResourceFiles(dir, projectConfig),
482
config: configFiles,
483
configResources: projectConfigResources(dir, projectConfig),
484
};
485
return await returnResult(result);
486
}
487
} else {
488
const nextDir = dirname(dir);
489
if (nextDir === dir) {
490
if (configResolvers.length > 1) {
491
// reset dir and proceed to next resolver
492
dir = originalDir;
493
configResolvers.shift();
494
} else if (force) {
495
const temp = createTempContext({
496
dir: join(originalDir, ".quarto"),
497
prefix: "quarto-session-temp",
498
});
499
const fileInformationCache = new FileInformationCacheMap();
500
const context: ProjectContext = {
501
clone: () => context,
502
resolveBrand: async (fileName?: string) =>
503
projectResolveBrand(context, fileName),
504
resolveFullMarkdownForFile: (
505
engine: ExecutionEngineInstance | undefined,
506
file: string,
507
markdown?: MappedString,
508
force?: boolean,
509
) => {
510
return projectResolveFullMarkdownForFile(
511
context,
512
engine,
513
file,
514
markdown,
515
force,
516
);
517
},
518
dir: originalDir,
519
engines: [],
520
config: {
521
project: {
522
[kProjectOutputDir]: flags?.outputDir,
523
},
524
},
525
fileInformationCache,
526
files: {
527
input: [],
528
},
529
renderFormats,
530
environment: () => environment(context),
531
notebookContext,
532
fileExecutionEngineAndTarget: (
533
file: string,
534
) => {
535
return fileExecutionEngineAndTarget(
536
file,
537
flags,
538
context,
539
);
540
},
541
fileMetadata: async (file: string, force?: boolean) => {
542
return projectFileMetadata(context, file, force);
543
},
544
isSingleFile: false,
545
previewServer: renderOptions?.previewServer,
546
diskCache: await createProjectCache(join(temp.baseDir, ".quarto")),
547
temp,
548
cleanup: () => {
549
cleanupFileInformationCache(context);
550
context.diskCache.close();
551
temp.cleanup();
552
},
553
};
554
if (Deno.statSync(path).isDirectory) {
555
const { files, engines } = await projectInputFiles(context);
556
context.engines = engines;
557
context.files.input = files;
558
} else {
559
const input = normalizePath(path);
560
const engine = await fileExecutionEngine(input, undefined, context);
561
context.engines = [engine?.name ?? kMarkdownEngine];
562
context.files.input = [input];
563
}
564
return await returnResult(context);
565
} else {
566
return undefined;
567
}
568
} else {
569
dir = nextDir;
570
}
571
}
572
}
573
}
574
575
type ResolvedProjectConfig = {
576
config: ProjectConfig;
577
files: string[];
578
};
579
580
function quartoYamlProjectConfigResolver(
581
configSchema: ConcreteSchema,
582
) {
583
return async (dir: string): Promise<ResolvedProjectConfig | undefined> => {
584
let configFile: string | undefined = undefined;
585
try {
586
configFile = projectConfigFile(dir);
587
} catch (e) {
588
if (e instanceof Deno.errors.PermissionDenied) {
589
// ignore permission denied errors
590
} else {
591
throw e;
592
}
593
}
594
if (configFile) {
595
// read config file
596
const files = [configFile];
597
const errMsg = `Project ${configFile} validation failed.`;
598
let config = (await readAndValidateYamlFromFile(
599
configFile,
600
configSchema,
601
errMsg,
602
kDefaultProjectFileContents,
603
)) as ProjectConfig;
604
config.project = config.project || {};
605
606
// resolve includes
607
const includedMeta = await includedMetadata(
608
dir,
609
config,
610
configSchema,
611
);
612
const metadata = includedMeta.metadata;
613
files.push(...includedMeta.files);
614
config = mergeProjectMetadata(config, metadata);
615
delete config[kMetadataFile];
616
delete config[kMetadataFiles];
617
return { config, files };
618
} else {
619
return undefined;
620
}
621
};
622
}
623
624
type ProjectTypeDetector = {
625
type: string;
626
detect: string[][];
627
};
628
629
async function projectExtensionsConfigResolver(
630
context: ExtensionContext,
631
dir: string,
632
) {
633
// load built-in project types and see if they have detectors
634
const projectTypeDetectors: ProjectTypeDetector[] =
635
(await context.extensions(dir)).reduce(
636
(projectTypeDetectors, extension) => {
637
if (extension.contributes.project) {
638
const project = extension.contributes.project as
639
| ProjectConfig
640
| undefined;
641
if (project?.project?.detect) {
642
const detect = asArray<string[]>(project?.project.detect);
643
projectTypeDetectors.push({
644
type: extension.id.name,
645
detect,
646
});
647
}
648
}
649
650
return projectTypeDetectors;
651
},
652
[] as ProjectTypeDetector[],
653
);
654
655
// function that will run the detectors on a directory
656
return (dir: string): Promise<ResolvedProjectConfig | undefined> => {
657
// look for the detector files
658
for (const detector of projectTypeDetectors) {
659
if (
660
detector.detect.some((files) =>
661
files.every((file) => safeExistsSync(join(dir, file)))
662
)
663
) {
664
return Promise.resolve({
665
config: {
666
project: {
667
type: detector.type,
668
},
669
},
670
files: [],
671
});
672
}
673
}
674
return Promise.resolve(undefined);
675
};
676
}
677
678
async function resolveProjectExtension(
679
context: ExtensionContext,
680
projectType: string,
681
projectConfig: ProjectConfig,
682
dir: string,
683
) {
684
// Find extensions
685
const extensions = await context.find(
686
projectType,
687
dir,
688
"project",
689
projectConfig,
690
dir,
691
);
692
693
// filter the extensions to resolve duplication
694
const filtered = filterExtensions(extensions, projectType, "project");
695
696
if (filtered.length > 0) {
697
const extension = filtered[0];
698
const projectExt = extension.contributes.project;
699
700
if (projectExt) {
701
// alias and clone (as we may mutate)
702
const projectExtConfig = ld.cloneDeep(projectExt) as ProjectConfig;
703
704
// remove the 'detect' field from the ext as that's just for bootstrapping
705
delete projectExtConfig.project.detect;
706
707
// user render config should fully override the extension config
708
if (projectConfig.project.render) {
709
delete projectExtConfig.project.render;
710
}
711
712
// Ensure that we replace the project type with a
713
// system supported project type (rather than the extension name)
714
const extProjType = () => {
715
const projectMeta = projectExt.project;
716
if (projectMeta && typeof projectMeta === "object") {
717
const extType = (projectMeta as Record<string, unknown>).type;
718
if (typeof extType === "string") {
719
return extType;
720
} else {
721
return "default";
722
}
723
} else {
724
return "default";
725
}
726
};
727
projectConfig.project[kProjectType] = extProjType();
728
729
// Merge config
730
projectConfig = mergeProjectMetadata(
731
projectExtConfig,
732
projectConfig,
733
);
734
}
735
}
736
return projectConfig;
737
}
738
739
export async function resolveEngineExtensions(
740
context: ExtensionContext,
741
projectConfig: ProjectConfig,
742
dir: string,
743
) {
744
// First, resolve any relative paths in existing project engines
745
if (projectConfig.engines) {
746
projectConfig.engines =
747
(projectConfig.engines as (string | ExternalEngine)[]).map(
748
(engine) => {
749
if (
750
typeof engine === "object" && engine.path &&
751
!isAbsolute(engine.path)
752
) {
753
// Convert relative path to absolute path based on project directory
754
return {
755
...engine,
756
path: join(dir, engine.path),
757
};
758
}
759
return engine;
760
},
761
);
762
}
763
764
// Find all extensions that contribute engines
765
const extensions = await context.extensions(
766
undefined,
767
projectConfig,
768
dir,
769
);
770
771
// Filter to only those with engines
772
const engineExtensions = extensions.filter((extension) =>
773
extension.contributes.engines !== undefined &&
774
extension.contributes.engines.length > 0
775
);
776
777
if (engineExtensions.length > 0) {
778
// Initialize engines array if needed
779
if (!projectConfig.engines) {
780
projectConfig.engines = [];
781
}
782
783
const existingEngines = projectConfig
784
.engines as (string | ExternalEngine)[];
785
786
// Extract and merge engines
787
const extensionEngines = engineExtensions
788
.map((extension) => extension.contributes.engines)
789
.flat();
790
791
projectConfig.engines = [...existingEngines, ...extensionEngines];
792
}
793
794
return projectConfig;
795
}
796
797
// migrate 'site' to 'website'
798
// TODO make this a deprecation warning
799
function migrateProjectConfig(projectConfig: ProjectConfig) {
800
const kSite = "site";
801
if (
802
projectConfig.project[kProjectType] !== kSite &&
803
projectConfig[kSite] === undefined
804
) {
805
return projectConfig;
806
}
807
808
projectConfig = ld.cloneDeep(projectConfig);
809
if (projectConfig.project[kProjectType] === kSite) {
810
projectConfig.project[kProjectType] = kWebsite;
811
}
812
if (projectConfig[kSite]) {
813
projectConfig[kWebsite] = ld.cloneDeep(projectConfig[kSite]);
814
delete projectConfig[kSite];
815
}
816
return projectConfig;
817
}
818
819
async function resolveLanguageTranslations(
820
projectConfig: ProjectConfig,
821
dir: string,
822
) {
823
const files: string[] = [];
824
825
// read any language file pointed to by the project
826
files.push(...(await resolveLanguageMetadata(projectConfig, dir)));
827
828
// read _language.yml and merge into the project
829
const translations = await readLanguageTranslations(
830
join(dir, "_language.yml"),
831
);
832
projectConfig[kLanguageDefaults] = mergeConfigs(
833
translations.language,
834
projectConfig[kLanguageDefaults],
835
);
836
files.push(...translations.files);
837
return files;
838
}
839
840
// read project context (if there is no project config file then still create
841
// a context (i.e. implicitly treat directory as a project)
842
export function projectContextForDirectory(
843
path: string,
844
notebookContext: NotebookContext,
845
renderOptions?: RenderOptions,
846
): Promise<ProjectContext> {
847
return projectContext(path, notebookContext, renderOptions, true) as Promise<
848
ProjectContext
849
>;
850
}
851
852
export function projectYamlFiles(dir: string): string[] {
853
const files: string[] = [];
854
855
// Be sure to ignore directory and paths that we shouldn't inspect
856
const projIgnoreGlobs = projectHiddenIgnoreGlob(dir);
857
858
// Walk through the directory discovering YAML files
859
for (
860
const walk of walkSync(dir, {
861
includeDirs: true,
862
// this was done b/c some directories e.g. renv/packrat and potentially python
863
// virtualenvs include symblinks to R or Python libraries that are in turn
864
// circular. much safer to not follow symlinks!
865
followSymlinks: false,
866
skip: [kSkipHidden].concat(
867
projIgnoreGlobs.map((ignore) => globToRegExp(join(dir, ignore) + SEP)),
868
),
869
})
870
) {
871
if (walk.isFile && isYamlPath(walk.path)) {
872
files.push(walk.path);
873
}
874
}
875
return files;
876
}
877
878
function projectHiddenIgnoreGlob(dir: string) {
879
return projectIgnoreGlobs(dir) // standard ignores for all projects
880
.concat(["**/_*", "**/_*/**"]) // underscore prefx
881
.concat(["**/.*", "**/.*/**"]) // hidden (dot prefix)
882
.concat(["**/README.?([Rrq])md"]) // README
883
.concat(["**/CLAUDE.md"]) // Anthropic claude code file
884
.concat(["**/AGENTS.md"]); // https://agents.md/
885
}
886
887
export const projectInputFiles = makeTimedFunctionAsync(
888
"projectInputFiles",
889
projectInputFilesInternal,
890
);
891
892
async function projectInputFilesInternal(
893
project: ProjectContext,
894
metadata?: ProjectConfig,
895
): Promise<{ files: string[]; engines: string[] }> {
896
// Resolve engines so engineIgnoreDirs() uses all engines (including external)
897
await resolveEngines(project);
898
899
const { dir } = project;
900
901
const outputDir = metadata?.project[kProjectOutputDir];
902
903
// Ignore project standard and hidden files
904
const projIgnoreGlobs = projectHiddenIgnoreGlob(dir);
905
906
// map to regex
907
const projectIgnores = projIgnoreGlobs.map((glob) =>
908
globToRegExp(glob, { extended: true, globstar: true })
909
);
910
911
type FileInclusion = {
912
file: string;
913
engineName: string;
914
engineIntermediates: string[];
915
};
916
917
const addFile = async (file: string): Promise<FileInclusion[]> => {
918
// ignore the file if it is in the output directory
919
if (
920
outputDir &&
921
ensureTrailingSlash(dirname(file)).startsWith(
922
ensureTrailingSlash(join(dir, outputDir)),
923
) &&
924
ensureTrailingSlash(join(dir, outputDir)).startsWith(
925
ensureTrailingSlash(dir),
926
)
927
) {
928
return [];
929
}
930
931
const engine = await fileExecutionEngine(file, undefined, project);
932
// ignore the file if there's no engine to handle it
933
if (!engine) {
934
return [];
935
}
936
const engineIntermediates = executionEngineIntermediateFiles(
937
engine,
938
file,
939
);
940
return [{
941
file,
942
engineName: engine.name,
943
engineIntermediates: engineIntermediates,
944
}];
945
};
946
const addDir = async (dir: string): Promise<FileInclusion[]> => {
947
const promises: Promise<FileInclusion[]>[] = [];
948
for await (
949
const walkEntry of walk(dir, {
950
includeDirs: false,
951
// this was done b/c some directories e.g. renv/packrat and potentially python
952
// virtualenvs include symblinks to R or Python libraries that are in turn
953
// circular. much safer to not follow symlinks!
954
followSymlinks: false,
955
skip: [kSkipHidden].concat(
956
engineIgnoreDirs().map((ignore) =>
957
globToRegExp(join(dir, ignore) + SEP)
958
),
959
),
960
})
961
) {
962
const pathRelative = pathWithForwardSlashes(
963
relative(dir, walkEntry.path),
964
);
965
if (projectIgnores.some((regex) => regex.test(pathRelative))) {
966
continue;
967
}
968
promises.push(addFile(walkEntry.path));
969
}
970
const inclusions = await Promise.all(promises);
971
return inclusions.flat();
972
};
973
const addEntry = async (entry: string) => {
974
if (Deno.statSync(entry).isDirectory) {
975
return addDir(entry);
976
} else {
977
return addFile(entry);
978
}
979
};
980
const renderFiles = metadata?.project[kProjectRender];
981
982
let inclusions: FileInclusion[];
983
if (renderFiles) {
984
const exclude = projIgnoreGlobs.concat(outputDir ? [outputDir] : []);
985
const resolved = resolvePathGlobs(dir, renderFiles, exclude, {
986
mode: "auto",
987
});
988
const toInclude = ld.difference(
989
resolved.include,
990
resolved.exclude,
991
) as string[];
992
inclusions = (await Promise.all(toInclude.map(addEntry))).flat();
993
} else {
994
inclusions = await addDir(dir);
995
}
996
997
const files = inclusions.map((inclusion) => inclusion.file);
998
const engines = ld.uniq(inclusions.map((inclusion) => inclusion.engineName));
999
const intermediateFiles = inclusions.map((inclusion) =>
1000
inclusion.engineIntermediates
1001
).flat();
1002
1003
const inputFiles = Array.from(
1004
new Set(files).difference(new Set(intermediateFiles)),
1005
);
1006
1007
return { files: inputFiles, engines };
1008
}
1009
1010
function projectConfigResources(
1011
dir: string,
1012
metadata: Metadata,
1013
type?: ProjectType,
1014
) {
1015
const resourceIgnoreFields = ignoreFieldsForProjectType(type);
1016
const resources: string[] = [];
1017
const findResources = (
1018
collection: Array<unknown> | Record<string, unknown>,
1019
parentKey?: unknown,
1020
) => {
1021
ld.forEach(
1022
collection,
1023
(value: unknown, index: unknown) => {
1024
if (parentKey === kHtmlMathMethod && index === "method") {
1025
// don't resolve html-math-method
1026
} else if (resourceIgnoreFields.includes(index as string)) {
1027
// project type specific ignore (e.g. site-navbar, site-sidebar)
1028
} else if (Array.isArray(value)) {
1029
findResources(value);
1030
} else if (typeof value === "object") {
1031
findResources(value as Record<string, unknown>, index);
1032
} else if (typeof value === "string") {
1033
const path = isAbsolute(value) ? value : join(dir, value);
1034
// Paths could be invalid paths (e.g. with colons or other weird characters)
1035
try {
1036
if (existsSync(path) && !Deno.statSync(path).isDirectory) {
1037
resources.push(normalizePath(path));
1038
}
1039
} catch {
1040
// Just ignore this error as the path must not be a local file path
1041
}
1042
}
1043
},
1044
);
1045
};
1046
1047
findResources(metadata);
1048
return resources;
1049
}
1050
1051