Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
quarto-dev
GitHub Repository: quarto-dev/quarto-cli
Path: blob/main/src/project/project-index.ts
6446 views
1
/*
2
* project-index.ts
3
*
4
* Copyright (C) 2020-2022 Posit Software, PBC
5
*/
6
7
import { dirname, isAbsolute, join, relative } from "../deno_ral/path.ts";
8
9
import * as ld from "../core/lodash.ts";
10
import { inputTargetIndexCacheMetrics } from "./target-index-cache-metrics.ts";
11
12
import {
13
InputTarget,
14
InputTargetIndex,
15
kProjectType,
16
ProjectContext,
17
} from "./types.ts";
18
import { Metadata } from "../config/types.ts";
19
import { Format } from "../config/types.ts";
20
21
import {
22
dirAndStem,
23
normalizePath,
24
pathWithForwardSlashes,
25
removeIfExists,
26
safeExistsSync,
27
} from "../core/path.ts";
28
import { kTitle } from "../config/constants.ts";
29
import { fileExecutionEngine } from "../execute/engine.ts";
30
31
import { projectConfigFile, projectOutputDir } from "./project-shared.ts";
32
import { projectScratchPath } from "./project-scratch.ts";
33
import { parsePandocTitle } from "../core/pandoc/pandoc-partition.ts";
34
import { readYamlFromString } from "../core/yaml.ts";
35
import { formatKeys } from "../config/metadata.ts";
36
import {
37
formatsPreferHtml,
38
websiteFormatPreferHtml,
39
} from "./types/website/website-config.ts";
40
import { kDefaultProjectFileContents } from "./types/project-default.ts";
41
import { formatOutputFile } from "../core/render.ts";
42
import { projectType } from "./types/project-types.ts";
43
import { withRenderServices } from "../command/render/render-services.ts";
44
import { RenderServices } from "../command/render/types.ts";
45
import { kDraft } from "../format/html/format-html-shared.ts";
46
47
export async function inputTargetIndex(
48
project: ProjectContext,
49
input: string,
50
): Promise<InputTargetIndex | undefined> {
51
// calculate input file
52
const inputFile = join(project.dir, input);
53
54
// return undefined if the file doesn't exist
55
if (!safeExistsSync(inputFile) || Deno.statSync(inputFile).isDirectory) {
56
return Promise.resolve(undefined);
57
}
58
59
// filter it out if its not in the list of input files
60
if (!project.files.input.includes(normalizePath(inputFile))) {
61
return Promise.resolve(undefined);
62
}
63
64
// see if we have an up to date index file (but not for notebooks
65
// as they could have ipynb-filters that vary based on config)
66
const { index: targetIndex } = readInputTargetIndex(
67
project.dir,
68
input,
69
);
70
71
// There is already a targetIndex entry, just use that
72
if (targetIndex) {
73
return targetIndex;
74
}
75
76
// Create an index entry for the input
77
const index = await readBaseInputIndex(inputFile, project);
78
if (index) {
79
const indexFile = inputTargetIndexFile(project.dir, input);
80
Deno.writeTextFileSync(indexFile, JSON.stringify(index));
81
}
82
return index;
83
}
84
85
export async function readBaseInputIndex(
86
inputFile: string,
87
project: ProjectContext,
88
) {
89
// check if this can be handled by one of our engines
90
const engine = await fileExecutionEngine(inputFile, undefined, project);
91
if (engine === undefined) {
92
return Promise.resolve(undefined);
93
}
94
95
// otherwise read the metadata and index it
96
const formats = await withRenderServices(
97
project.notebookContext,
98
(services: RenderServices) =>
99
project.renderFormats(inputFile, services, "all", project),
100
);
101
const firstFormat = Object.values(formats)[0];
102
const markdown = await engine.partitionedMarkdown(inputFile, firstFormat);
103
const index: InputTargetIndex = {
104
title: (firstFormat?.metadata?.[kTitle] || markdown.yaml?.[kTitle] ||
105
markdown.headingText) as
106
| string
107
| undefined,
108
markdown,
109
formats,
110
draft: (firstFormat?.metadata?.[kDraft] || markdown.yaml?.[kDraft]) as
111
| boolean
112
| undefined,
113
};
114
115
// if we got a title, make sure it doesn't carry attributes
116
if (index.title) {
117
// locally guard against a badly-formed title.
118
// See https://github.com/quarto-dev/quarto-cli/issues/8594 for why
119
// we can't do a proper fix at this time
120
if (typeof index.title !== "string") {
121
throw new Error(
122
`${
123
relative(project.dir, inputFile)
124
}: Title must be a string, but is instead of type ${typeof index
125
.title}`,
126
);
127
}
128
const parsedTitle = parsePandocTitle(index.title);
129
index.title = parsedTitle.heading;
130
} else {
131
// if there is no title then try to extract it from a header
132
index.title = index.markdown.headingText;
133
}
134
135
if (project.config) {
136
index.projectFormats = formatKeys(project.config);
137
}
138
139
return index;
140
}
141
142
// reads an existing input target index file
143
export function readInputTargetIndex(
144
projectDir: string,
145
input: string,
146
): {
147
index?: InputTargetIndex;
148
missingReason?: "stale" | "formats";
149
} {
150
// check if we have an index that's still current vis-a-vis the
151
// last modified date of the source file
152
const index = readInputTargetIndexIfStillCurrent(projectDir, input);
153
if (!index) {
154
return {
155
missingReason: "stale",
156
};
157
}
158
159
// if its still current vis-a-vis the input file, we then need to
160
// check if the format list has changed (which is also an invalidation)
161
162
// normalize html to first if its included in the formats
163
if (Object.keys(index.formats).includes("html")) {
164
// note that the cast it okay here b/c we know that index.formats
165
// includes only full format objects
166
index.formats = websiteFormatPreferHtml(index.formats) as Record<
167
string,
168
Format
169
>;
170
}
171
172
// when we write the index to disk we write it with the formats
173
// so we need to check if the formats have changed
174
const formats = (index.projectFormats as string[] | undefined) ??
175
Object.keys(index.formats);
176
const projConfigFile = projectConfigFile(projectDir);
177
if (!projConfigFile) {
178
return { index };
179
}
180
181
let contents = Deno.readTextFileSync(projConfigFile);
182
if (contents.trim().length === 0) {
183
contents = kDefaultProjectFileContents;
184
}
185
const config = readYamlFromString(contents) as Metadata;
186
const projFormats = formatKeys(config);
187
if (ld.isEqual(formats, projFormats)) {
188
return {
189
index,
190
};
191
} else {
192
return {
193
missingReason: "formats",
194
};
195
}
196
}
197
198
export function inputTargetIsEmpty(index: InputTargetIndex) {
199
// if we have markdown we are not empty
200
if (index.markdown.markdown.trim().length > 0) {
201
return false;
202
}
203
204
// if we have a key other than title we are not empty
205
if (
206
index.markdown.yaml &&
207
Object.keys(index.markdown.yaml).find((key) => key !== kTitle)
208
) {
209
return false;
210
}
211
212
// otherwise we are empty
213
return true;
214
}
215
216
const inputTargetIndexCache = new Map<string, InputTargetIndex>();
217
218
function readInputTargetIndexIfStillCurrent(projectDir: string, input: string) {
219
const inputFile = join(projectDir, input);
220
const indexFile = inputTargetIndexFile(projectDir, input);
221
try {
222
const inputMod = Deno.statSync(inputFile).mtime;
223
const indexMod = Deno.statSync(indexFile).mtime;
224
if (
225
inputMod && indexMod
226
) {
227
if (inputMod > indexMod) {
228
inputTargetIndexCacheMetrics.invalidations++;
229
inputTargetIndexCache.delete(indexFile);
230
return undefined;
231
}
232
233
if (inputTargetIndexCache.has(indexFile)) {
234
inputTargetIndexCacheMetrics.hits++;
235
return inputTargetIndexCache.get(indexFile);
236
} else {
237
inputTargetIndexCacheMetrics.misses++;
238
try {
239
const result = JSON.parse(
240
Deno.readTextFileSync(indexFile),
241
) as InputTargetIndex;
242
inputTargetIndexCache.set(indexFile, result);
243
return result;
244
} catch {
245
return undefined;
246
}
247
}
248
}
249
} catch (e) {
250
if (e instanceof Deno.errors.NotFound) {
251
return undefined;
252
} else {
253
throw e;
254
}
255
}
256
}
257
258
export async function resolveInputTarget(
259
project: ProjectContext,
260
href: string,
261
absolute = true,
262
): Promise<InputTarget | undefined> {
263
const index = await inputTargetIndex(project, href);
264
if (index) {
265
const formats = formatsPreferHtml(index.formats) as Record<string, Format>;
266
const format = Object.values(formats)[0];
267
268
// lookup the project type
269
const projType = projectType(project.config?.project?.[kProjectType]);
270
const projOutputFile = projType.outputFile
271
? projType.outputFile(href, format, project)
272
: undefined;
273
274
const [hrefDir, hrefStem] = dirAndStem(href);
275
const outputFile = projOutputFile || formatOutputFile(format) ||
276
`${hrefStem}.html`;
277
const outputHref = pathWithForwardSlashes(
278
(absolute ? "/" : "") + join(hrefDir, outputFile),
279
);
280
const inputTarget = {
281
input: href,
282
title: index.title,
283
outputHref,
284
draft: index.draft === true,
285
};
286
if (projType.filterInputTarget) {
287
return projType.filterInputTarget(inputTarget, project);
288
} else {
289
return inputTarget;
290
}
291
} else {
292
return undefined;
293
}
294
}
295
296
export async function inputFileForOutputFile(
297
project: ProjectContext,
298
output: string,
299
): Promise<{ file: string; format: Format } | undefined> {
300
// compute output dir
301
const outputDir = projectOutputDir(project);
302
303
// full path to output (it's relative to output dir)
304
output = isAbsolute(output) ? output : join(outputDir, output);
305
306
if (project.outputNameIndex !== undefined) {
307
return project.outputNameIndex.get(output);
308
}
309
310
project.outputNameIndex = new Map();
311
for (const file of project.files.input) {
312
const inputRelative = relative(project.dir, file);
313
const index = await inputTargetIndex(
314
project,
315
relative(project.dir, file),
316
);
317
if (index) {
318
Object.keys(index.formats).forEach((key) => {
319
const format = index.formats[key];
320
const outputFile = formatOutputFile(format);
321
if (outputFile) {
322
const formatOutputPath = join(
323
outputDir!,
324
dirname(inputRelative),
325
outputFile,
326
);
327
project.outputNameIndex!.set(formatOutputPath, { file, format });
328
}
329
});
330
}
331
}
332
return project.outputNameIndex.get(output);
333
}
334
335
export async function inputTargetIndexForOutputFile(
336
project: ProjectContext,
337
outputRelative: string,
338
) {
339
const input = await inputFileForOutputFile(project, outputRelative);
340
if (!input) {
341
return undefined;
342
}
343
344
return await inputTargetIndex(
345
project,
346
relative(project.dir, input.file),
347
);
348
}
349
350
export async function resolveInputTargetForOutputFile(
351
project: ProjectContext,
352
outputRelative: string,
353
) {
354
const input = await inputFileForOutputFile(project, outputRelative);
355
if (!input) {
356
return undefined;
357
}
358
359
return await resolveInputTarget(
360
project,
361
pathWithForwardSlashes(relative(project.dir, input.file)),
362
);
363
}
364
365
export function clearProjectIndex(projectDir: string) {
366
const indexPath = projectScratchPath(projectDir, "idx");
367
removeIfExists(indexPath);
368
}
369
370
function inputTargetIndexFile(projectDir: string, input: string): string {
371
return indexPath(projectDir, `${input}.json`);
372
}
373
374
function indexPath(projectDir: string, path: string): string {
375
return projectScratchPath(projectDir, join("idx", path));
376
}
377
378