Skip to content

Commit ca0865b

Browse files
authored
add yaml intelligence for _extension.yml (#1746)
* add schema for _extension.yml and enforce it at render time * update tests * changelog
1 parent 60a9fe9 commit ca0865b

File tree

20 files changed

+432
-119
lines changed

20 files changed

+432
-119
lines changed

news/changelog-1.1.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
## Extensions
7777

7878
- Properly copy `format-resources` for HTML based formats
79+
- Extension YAML files `_extension.yml` are now validated at render time. (#1268)
7980

8081
## Publishing
8182

src/command/list/cmd.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ async function outputExtensions(
6262
) {
6363
// Provide the with with a list
6464
const project = await projectContext(path);
65-
const extensions = extensionContext.extensions(path, project);
65+
const extensions = await extensionContext.extensions(path, project);
6666
if (extensions.length === 0) {
6767
info(
6868
`No extensions are installed in this ${

src/command/remove/cmd.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
selectTool,
2626
} from "../../tools/tools-console.ts";
2727
import { haveArrowKeys } from "../../core/platform.ts";
28-
import { join } from "path/mod.ts";
2928

3029
export const removeCommand = new Command()
3130
.hidden()
@@ -78,12 +77,12 @@ export const removeCommand = new Command()
7877
// Not provided, give the user a list to select from
7978
const workingDir = Deno.cwd();
8079

81-
const resolveTargetDir = () => {
80+
const resolveTargetDir = async () => {
8281
if (options.embed) {
8382
// We're removing an embedded extension, lookup the extension
8483
// and use its path
8584
const context = createExtensionContext();
86-
const extension = context.extension(
85+
const extension = await context.extension(
8786
options.embed,
8887
workingDir,
8988
);
@@ -97,12 +96,12 @@ export const removeCommand = new Command()
9796
return workingDir;
9897
}
9998
};
100-
const targetDir = resolveTargetDir();
99+
const targetDir = await resolveTargetDir();
101100

102101
// Process extension
103102
if (target) {
104103
// explicitly provided
105-
const extensions = extensionContext.find(target, targetDir);
104+
const extensions = await extensionContext.find(target, targetDir);
106105
if (extensions.length > 0) {
107106
await removeExtensions(extensions.slice(), options.prompt);
108107
} else {
@@ -111,7 +110,10 @@ export const removeCommand = new Command()
111110
} else {
112111
// Provide the with with a list
113112
const project = await projectContext(targetDir);
114-
const extensions = extensionContext.extensions(targetDir, project);
113+
const extensions = await extensionContext.extensions(
114+
targetDir,
115+
project,
116+
);
115117

116118
// Show a list
117119
if (extensions.length > 0) {

src/command/render/defaults.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
import { TempContext } from "../../core/temp.ts";
4040
import { authorsFilter } from "./authors.ts";
4141

42-
export function generateDefaults(
42+
export async function generateDefaults(
4343
options: PandocOptions,
4444
): Promise<FormatPandoc | undefined> {
4545
let allDefaults: FormatPandoc | undefined;
@@ -48,7 +48,7 @@ export function generateDefaults(
4848
allDefaults = options.format.pandoc || {};
4949

5050
// resolve filters
51-
const resolvedFilters = resolveFilters(
51+
const resolvedFilters = await resolveFilters(
5252
[
5353
...(allDefaults[kFilters] || []),
5454
],
@@ -67,9 +67,9 @@ export function generateDefaults(
6767
}
6868
}
6969

70-
return Promise.resolve(allDefaults);
70+
return allDefaults;
7171
} else {
72-
return Promise.resolve(undefined);
72+
return undefined;
7373
}
7474
}
7575

src/command/render/filters.ts

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ import {
5353
import { layoutFilter, layoutFilterParams } from "./layout.ts";
5454
import { pandocMetadataPath } from "./render-paths.ts";
5555
import { removePandocArgs } from "./flags.ts";
56-
import * as ld from "../../core/lodash.ts";
5756
import { mergeConfigs } from "../../core/config.ts";
5857
import { projectType } from "../../project/types/project-types.ts";
5958
import { readCodePage } from "../../core/windows.ts";
@@ -74,7 +73,7 @@ const kTimingFile = "timings-file";
7473

7574
const kHasBootstrap = "has-bootstrap";
7675

77-
export function filterParamsJson(
76+
export async function filterParamsJson(
7877
args: string[],
7978
options: PandocOptions,
8079
defaults: FormatPandoc | undefined,
@@ -105,7 +104,7 @@ export function filterParamsJson(
105104
...ipynbFilterParams(options),
106105
...projectFilterParams(options),
107106
...quartoColumnParams,
108-
...quartoFilterParams(options, defaults),
107+
...await quartoFilterParams(options, defaults),
109108
...crossrefFilterParams(options, defaults),
110109
...layoutFilterParams(options.format),
111110
...languageFilterParams(options.format.language),
@@ -262,7 +261,7 @@ function extractSmartIncludeInternal(
262261
if (value === undefined) {
263262
return [];
264263
}
265-
if (ld.isArray(value)) {
264+
if (Array.isArray(value)) {
266265
const contents = value.filter(isContent);
267266
const nonContents = value.filter((v) => !isContent(v));
268267
obj[name] = nonContents;
@@ -289,7 +288,7 @@ function extractIncludeVariables(obj: { [key: string]: unknown }) {
289288
delete obj[name];
290289
if (!value) {
291290
return [];
292-
} else if (ld.isArray(value)) {
291+
} else if (Array.isArray(value)) {
293292
return value as unknown[];
294293
} else {
295294
return [value];
@@ -414,7 +413,7 @@ function ipynbFilterParams(options: PandocOptions) {
414413
};
415414
}
416415

417-
function quartoFilterParams(
416+
async function quartoFilterParams(
418417
options: PandocOptions,
419418
defaults?: FormatPandoc,
420419
) {
@@ -449,7 +448,7 @@ function quartoFilterParams(
449448
if (shortcodes !== undefined) {
450449
params[kShortcodes] = shortcodes;
451450
}
452-
const extShortcodes = extensionShortcodes(options);
451+
const extShortcodes = await extensionShortcodes(options);
453452
if (extShortcodes) {
454453
params[kShortcodes] = params[kShortcodes] || [];
455454
(params[kShortcodes] as string[]).push(...extShortcodes);
@@ -477,10 +476,10 @@ function quartoFilterParams(
477476
return params;
478477
}
479478

480-
function extensionShortcodes(options: PandocOptions) {
479+
async function extensionShortcodes(options: PandocOptions) {
481480
const extensionShortcodes: string[] = [];
482481
if (options.extension) {
483-
const allExtensions = options.extension?.extensions(
482+
const allExtensions = await options.extension?.extensions(
484483
options.source,
485484
options.project,
486485
);
@@ -508,10 +507,10 @@ function initFilterParams(dependenciesFile: string) {
508507
const kQuartoFilterMarker = "quarto";
509508
const kQuartoCiteProcMarker = "citeproc";
510509

511-
export function resolveFilters(
510+
export async function resolveFilters(
512511
filters: QuartoFilter[],
513512
options: PandocOptions,
514-
): QuartoFilter[] | undefined {
513+
): Promise<QuartoFilter[] | undefined> {
515514
// build list of quarto filters
516515

517516
// The default order of filters will be
@@ -532,7 +531,7 @@ export function resolveFilters(
532531
quartoFilters.push(quartoPostFilter());
533532

534533
// Resolve any filters that are provided by an extension
535-
filters = resolveFilterExtension(options, filters);
534+
filters = await resolveFilterExtension(options, filters);
536535

537536
// if 'quarto' is in the filters, inject our filters at that spot,
538537
// otherwise inject them at the beginning so user filters can take
@@ -613,20 +612,21 @@ function pdfEngine(options: PandocOptions): string {
613612
const kQuartoExtOrganization = "quarto-ext";
614613
const kQuartoExtBuiltIn = ["code-filename", "grouped-tabsets"];
615614

616-
function resolveFilterExtension(
615+
async function resolveFilterExtension(
617616
options: PandocOptions,
618617
filters: QuartoFilter[],
619-
): QuartoFilter[] {
618+
): Promise<QuartoFilter[]> {
620619
// Resolve any filters that are provided by an extension
621-
const results = filters.flatMap((filter) => {
620+
const results: (QuartoFilter | QuartoFilter[])[] = [];
621+
const getFilter = async (filter: QuartoFilter) => {
622622
// Look for extension names in the filter list and result them
623623
// into the filters provided by the extension
624624
if (
625625
filter !== kQuartoFilterMarker && filter !== kQuartoCiteProcMarker &&
626626
typeof (filter) === "string" &&
627627
!existsSync(filter)
628628
) {
629-
let extensions = options.extension?.find(
629+
let extensions = await options.extension?.find(
630630
filter,
631631
options.source,
632632
"filters",
@@ -672,6 +672,10 @@ function resolveFilterExtension(
672672
} else {
673673
return filter;
674674
}
675-
});
676-
return results;
675+
};
676+
for (const filter of filters) {
677+
const r = await getFilter(filter);
678+
results.push(r);
679+
}
680+
return results.flat();
677681
}

src/command/render/pandoc.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ export async function runPandoc(
224224
cleanMetadataForPrinting(printMetadata);
225225

226226
// generate defaults and capture defaults to be printed
227-
let allDefaults = await generateDefaults(options) || {};
227+
let allDefaults = (await generateDefaults(options)) || {};
228228
let printAllDefaults = ld.cloneDeep(allDefaults) as FormatPandoc;
229229

230230
// capture any filterParams in the FormatExtras
@@ -654,7 +654,7 @@ export async function runPandoc(
654654
// set parameters required for filters (possibily mutating all of it's arguments
655655
// to pull includes out into quarto parameters so they can be merged)
656656
let pandocArgs = args;
657-
const paramsJson = filterParamsJson(
657+
const paramsJson = await filterParamsJson(
658658
pandocArgs,
659659
options,
660660
allDefaults,

src/command/render/render-contexts.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -572,7 +572,7 @@ const readExtensionFormat = async (
572572
// Read the format file and populate this
573573
if (formatDesc.extension) {
574574
// Find the yaml file
575-
const extension = extensionContext.extension(
575+
const extension = await extensionContext.extension(
576576
formatDesc.extension,
577577
file,
578578
project,

src/command/use/commands/template.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ async function useTemplate(
7373
// Copy extensions
7474
const extDir = join(stagedDir, kExtensionDir);
7575
if (existsSync(extDir)) {
76-
copyExtensions(source, extDir, outputDirectory);
76+
await copyExtensions(source, extDir, outputDirectory);
7777
}
7878

7979
for (const fileToCopy of filesToCopy) {

src/core/lib/yaml-intelligence/yaml-intelligence.ts

Lines changed: 52 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,10 @@ import {
6565
import { getFormatAliases } from "../yaml-schema/format-aliases.ts";
6666
import { getFrontMatterSchema } from "../yaml-schema/front-matter.ts";
6767
import { getEngineOptionsSchema } from "../yaml-schema/chunk-metadata.ts";
68-
import { getProjectConfigSchema } from "../yaml-schema/project-config.ts";
68+
import {
69+
getExtensionConfigSchema,
70+
getProjectConfigSchema,
71+
} from "../yaml-schema/project-config.ts";
6972
import {
7073
getYamlIntelligenceResource,
7174
setYamlIntelligenceResources,
@@ -1041,25 +1044,58 @@ function exportSmokeTest(
10411044
console.error(JSON.stringify({ kind, context }, null, 2));
10421045
}
10431046

1047+
const determineSchema = async (context: YamlIntelligenceContext): Promise<{
1048+
schema: ConcreteSchema | undefined;
1049+
schemaName: string | undefined;
1050+
}> => {
1051+
const extension = context.path === null
1052+
? ""
1053+
: (context.path.split(".").pop() || "");
1054+
1055+
if (context.filetype !== "yaml") {
1056+
return {
1057+
schema: undefined,
1058+
schemaName: undefined,
1059+
};
1060+
}
1061+
1062+
if (extension === "qmd") {
1063+
const frontMatterSchema = await getFrontMatterSchema();
1064+
return {
1065+
schema: frontMatterSchema,
1066+
schemaName: "front-matter",
1067+
};
1068+
}
1069+
const extensionConfigNames = [
1070+
"_extension.yml",
1071+
"_extension.yaml",
1072+
];
1073+
if (
1074+
context.path &&
1075+
extensionConfigNames.some((name) => context.path!.endsWith(name))
1076+
) {
1077+
const extensionConfigSchema = await getExtensionConfigSchema();
1078+
return {
1079+
schema: extensionConfigSchema,
1080+
schemaName: "extension-config",
1081+
};
1082+
} else {
1083+
const projectConfigSchema = await getProjectConfigSchema();
1084+
return {
1085+
schema: projectConfigSchema,
1086+
schemaName: "project-config",
1087+
};
1088+
}
1089+
};
1090+
10441091
export async function getAutomation(
10451092
kind: AutomationKind,
10461093
context: YamlIntelligenceContext,
10471094
) {
1048-
const extension = context.path === null
1049-
? ""
1050-
: (context.path.split(".").pop() || "");
1051-
const frontMatterSchema = await getFrontMatterSchema();
1052-
const projectConfigSchema = await getProjectConfigSchema();
1053-
const schema = ({
1054-
"yaml": extension === "qmd" ? frontMatterSchema : projectConfigSchema,
1055-
"markdown": undefined, // can't be known ahead of time
1056-
"script": undefined,
1057-
})[context.filetype];
1058-
const schemaName = ({
1059-
"yaml": extension === "qmd" ? "front-matter" : "config",
1060-
"markdown": undefined, // can't be known ahead of time
1061-
"script": undefined,
1062-
})[context.filetype];
1095+
const {
1096+
schema,
1097+
schemaName,
1098+
} = await determineSchema(context);
10631099

10641100
const result = await automationFileTypeDispatch(context.filetype, kind, {
10651101
...context,

src/core/lib/yaml-schema/project-config.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,19 @@ export const getProjectConfigFieldsSchema = defineCached(
4242
"project-config-fields",
4343
);
4444

45+
export const getExtensionConfigFieldsSchema = defineCached(
46+
// deno-lint-ignore require-await
47+
async () => {
48+
return {
49+
schema: objectSchemaFromFieldsObject(
50+
getYamlIntelligenceResource("schema/extension.yml") as SchemaField[],
51+
),
52+
errorHandlers: [],
53+
};
54+
},
55+
"extension-config-fields",
56+
);
57+
4558
function disallowTopLevelType(
4659
error: LocalizedError,
4760
parse: AnnotatedParse,
@@ -101,3 +114,17 @@ export const getProjectConfigSchema = defineCached(
101114
},
102115
"project-config",
103116
);
117+
118+
export const getExtensionConfigSchema = defineCached(
119+
async () => {
120+
const extensionConfig = await getExtensionConfigFieldsSchema();
121+
return {
122+
schema: describeSchema(
123+
extensionConfig,
124+
"an extension configuration object",
125+
),
126+
errorHandlers: [],
127+
};
128+
},
129+
"extension-config",
130+
);

0 commit comments

Comments
 (0)