Skip to content

Commit 68999ef

Browse files
committed
refactor(manifest): move manifest template resolver into @microsoft/app-manifest
Relocate the DriverContext-free parts of fx-core's manifest templating into @microsoft/app-manifest so the logic can be consumed without fx-core's DriverContext, localization, or FxError: - expandEnvironmentVariable / getEnvironmentVariables (moved from fx-core component/utils/common.ts) - the file() function resolver and file reader (moved from fx-core component/utils/envFunctionUtils.ts), decoupled from DriverContext and raising plain typed errors that carry the offending path/token - the ManifestType enum Also expose expandFileFunctionMacros (the resolution loop, returning the expanded content plus a function count for host telemetry) and resolveManifest, the host-agnostic counterpart of fx-core's getResolvedManifest. Reuses the package's existing strip-bom and fs-extra usage (no new dependencies). Adds unit tests covering file inlining, JSON escaping, BOM/CRLF normalization, nested file() calls, env-as-parameter, ApiSpec skip, and every typed error path.
1 parent cb22bf8 commit 68999ef

3 files changed

Lines changed: 465 additions & 0 deletions

File tree

packages/manifest/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { PluginManifestSchema } from "./pluginManifest";
2121
export * from "./declarativeCopilotManifest";
2222
export * from "./generated-types";
2323
export * from "./manifest";
24+
export * from "./manifestTemplate";
2425
export * from "./pluginManifest";
2526
export * from "./wrappers";
2627

Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
// Host-agnostic resolution of manifest templating: the `${{ENV}}` environment
5+
// variable syntax and the `$[file('<path>')]` function syntax. This logic was
6+
// previously entangled with fx-core's DriverContext (telemetry, logging, i18n,
7+
// FxError). It is relocated here so consumers such as the SPFx build pipeline can
8+
// resolve declarative-agent manifests without depending on @microsoft/teamsfx-core
9+
// or a DriverContext. fx-core keeps telemetry and localized-error mapping and
10+
// delegates the resolution itself to the functions below.
11+
12+
import path from "path";
13+
import fs from "fs-extra";
14+
import stripBom from "strip-bom";
15+
16+
const placeholderRegex = /\${{ *[a-zA-Z_][a-zA-Z0-9_]* *}}/g;
17+
const functionRegex = /\$\[ *[a-zA-Z][a-zA-Z]*\([^\]]*\) *\]/g;
18+
19+
export enum ManifestType {
20+
TeamsManifest = "teams-manifest",
21+
PluginManifest = "plugin-manifest",
22+
DeclarativeCopilotManifest = "declarative-copilot-manifest",
23+
ApiSpec = "api-spec",
24+
EmbeddedKnowledgeFile = "embedded-knowledge-file",
25+
}
26+
27+
export interface ResolveManifestOptions {
28+
// Explicit environment map. When omitted, process.env is used.
29+
envs?: { [key in string]: string };
30+
// Absolute path of the manifest file being resolved. `file()` paths are
31+
// resolved relative to this file's directory.
32+
fromPath: string;
33+
manifestType: ManifestType;
34+
// Optional diagnostic sink, replacing DriverContext.logProvider.
35+
logger?: { error: (message: string) => void };
36+
}
37+
38+
// Base error for all template-resolution failures. Consumers can catch this to
39+
// distinguish resolution errors from unexpected exceptions. Subclasses carry the
40+
// offending path/token so a host (e.g. fx-core) can remap them to its own errors.
41+
export class ManifestTemplateError extends Error {
42+
constructor(message: string) {
43+
super(message);
44+
this.name = "ManifestTemplateError";
45+
}
46+
}
47+
48+
export class UnsupportedFileFormatError extends ManifestTemplateError {
49+
constructor(public readonly filePath: string) {
50+
super("The file to be embedded must be a .txt or .md file.");
51+
this.name = "UnsupportedFileFormatError";
52+
}
53+
}
54+
55+
export class InvalidFunctionError extends ManifestTemplateError {
56+
constructor(public readonly token: string) {
57+
super("Unsupported function. Only the 'file' function is supported.");
58+
this.name = "InvalidFunctionError";
59+
}
60+
}
61+
62+
export class InvalidFunctionParameterError extends ManifestTemplateError {
63+
constructor(public readonly token: string) {
64+
super("Invalid parameter for the 'file' function.");
65+
this.name = "InvalidFunctionParameterError";
66+
}
67+
}
68+
69+
export class ReadFileError extends ManifestTemplateError {
70+
constructor(
71+
public readonly filePath: string,
72+
public readonly cause?: unknown
73+
) {
74+
super(`Failed to read file '${filePath}'.`);
75+
this.name = "ReadFileError";
76+
}
77+
}
78+
79+
export class FileNotFoundError extends ManifestTemplateError {
80+
constructor(public readonly filePath: string) {
81+
super(`File not found: '${filePath}'.`);
82+
this.name = "FileNotFoundError";
83+
}
84+
}
85+
86+
export class MissingEnvironmentVariablesError extends ManifestTemplateError {
87+
constructor(public readonly names: string) {
88+
super(`The following environment variables are not defined: ${names}.`);
89+
this.name = "MissingEnvironmentVariablesError";
90+
}
91+
}
92+
93+
// Expand `${{ENV_NAME}}` references in content. A value not present in
94+
// `envs`/process.env leaves the placeholder untouched, except APP_NAME_SUFFIX
95+
// which is substituted whenever it is explicitly set (including empty string).
96+
export function expandEnvironmentVariable(
97+
content: string,
98+
envs?: { [key in string]: string }
99+
): string {
100+
const placeholders = content.match(placeholderRegex);
101+
if (placeholders) {
102+
for (const placeholder of placeholders) {
103+
const envName = placeholder.slice(3, -2).trim(); // removes `${{` and `}}`
104+
const envValue = envs ? envs[envName] : process.env[envName];
105+
if (envName === "APP_NAME_SUFFIX") {
106+
if (envValue !== undefined && envValue !== null) {
107+
content = content.replace(placeholder, envValue);
108+
}
109+
} else {
110+
if (envValue) {
111+
content = content.replace(placeholder, envValue);
112+
}
113+
}
114+
}
115+
}
116+
return content;
117+
}
118+
119+
// Return the de-duplicated list of `${{ENV_NAME}}` variables referenced in content.
120+
export function getEnvironmentVariables(content: string): string[] {
121+
const placeholders = content.match(placeholderRegex);
122+
if (placeholders) {
123+
const variables = placeholders.map((placeholder) => placeholder.slice(3, -2).trim());
124+
return [...new Set(variables)];
125+
}
126+
return [];
127+
}
128+
129+
function getAbsolutePath(relativeOrAbsolutePath: string, fromPath: string): string {
130+
return path.isAbsolute(relativeOrAbsolutePath)
131+
? relativeOrAbsolutePath
132+
: path.join(path.dirname(fromPath), relativeOrAbsolutePath);
133+
}
134+
135+
async function readFileContent(
136+
filePath: string,
137+
envs: { [key in string]: string } | undefined,
138+
fromPath: string,
139+
logger?: { error: (message: string) => void }
140+
): Promise<string> {
141+
const ext = path.extname(filePath).toLowerCase();
142+
if (ext !== ".txt" && ext !== ".md") {
143+
logger?.error(`Unsupported file '${filePath}'. Only .txt and .md files are supported.`);
144+
throw new UnsupportedFileFormatError(filePath);
145+
}
146+
147+
const absolutePath = getAbsolutePath(filePath, fromPath);
148+
if (await fs.pathExists(absolutePath)) {
149+
try {
150+
let fileContent = await fs.readFile(absolutePath, "utf8");
151+
fileContent = stripBom(fileContent);
152+
let processedFileContent = expandEnvironmentVariable(fileContent, envs);
153+
processedFileContent = processedFileContent.replace(/\r\n/g, "\n");
154+
return processedFileContent;
155+
} catch (e) {
156+
logger?.error(`Failed to read file '${absolutePath}': ${(e as Error)?.toString()}`);
157+
throw new ReadFileError(absolutePath, e);
158+
}
159+
}
160+
throw new FileNotFoundError(filePath);
161+
}
162+
163+
// Resolve a single `file(...)` call (the text inside `$[ ... ]`) to its embedded,
164+
// env-expanded file content. Supports a single-quoted static path, a `${{env}}`
165+
// parameter, and a nested `file(file(...))` call.
166+
export async function processManifestFunction(
167+
content: string,
168+
envs: { [key in string]: string } | undefined,
169+
fromPath: string,
170+
logger?: { error: (message: string) => void }
171+
): Promise<string> {
172+
const firstTrimmedContent = content.trim();
173+
if (!firstTrimmedContent.startsWith("file(") || !firstTrimmedContent.endsWith(")")) {
174+
logger?.error(`Unsupported function '${firstTrimmedContent}'. Only 'file' is supported.`);
175+
throw new InvalidFunctionError(firstTrimmedContent);
176+
}
177+
178+
const trimmedParameter = content.slice(5, -1).trim();
179+
if (trimmedParameter[0] === "'" && trimmedParameter[trimmedParameter.length - 1] === "'") {
180+
// static string as function parameter
181+
return readFileContent(
182+
trimmedParameter.substring(1, trimmedParameter.length - 1),
183+
envs,
184+
fromPath,
185+
logger
186+
);
187+
} else if (trimmedParameter.startsWith("${{") && trimmedParameter.endsWith("}}")) {
188+
// env variable inside
189+
const resolvedParameter = expandEnvironmentVariable(trimmedParameter, envs);
190+
return readFileContent(resolvedParameter, envs, fromPath, logger);
191+
} else if (trimmedParameter.startsWith("file(") && trimmedParameter.endsWith(")")) {
192+
// nested function inside
193+
const nested = await processManifestFunction(trimmedParameter, envs, fromPath, logger);
194+
return readFileContent(nested, envs, fromPath, logger);
195+
} else {
196+
logger?.error(`Invalid parameter '${trimmedParameter}' for the 'file' function.`);
197+
throw new InvalidFunctionParameterError(trimmedParameter);
198+
}
199+
}
200+
201+
// Expand every `$[file('<path>')]` call in content. When `isJson` is true the
202+
// embedded content is JSON-string escaped so it can be inlined into a JSON string.
203+
// Returns the resolved content along with the number of calls that produced a
204+
// value, so a host can report telemetry.
205+
export async function expandFileFunctionMacros(
206+
content: string,
207+
isJson: boolean,
208+
options: Pick<ResolveManifestOptions, "envs" | "fromPath" | "logger">
209+
): Promise<{ content: string; functionCount: number }> {
210+
const matches = content.match(functionRegex);
211+
if (!matches) {
212+
return { content, functionCount: 0 };
213+
}
214+
let functionCount = 0;
215+
for (const placeholder of matches) {
216+
let value = await processManifestFunction(
217+
placeholder.slice(2, -1).trim(),
218+
options.envs,
219+
options.fromPath,
220+
options.logger
221+
);
222+
if (isJson && value) {
223+
value = JSON.stringify(value).slice(1, -1);
224+
}
225+
if (value) {
226+
functionCount += 1;
227+
content = content.replace(placeholder, value);
228+
}
229+
}
230+
return { content, functionCount };
231+
}
232+
233+
// Fully resolve a manifest template string: expand `$[file()]` calls (except for
234+
// ApiSpec) then `${{ENV}}` variables, failing if any variable is left unresolved.
235+
// This is the host-agnostic counterpart of fx-core's getResolvedManifest.
236+
export async function resolveManifest(
237+
content: string,
238+
options: ResolveManifestOptions
239+
): Promise<string> {
240+
let value = content;
241+
if (options.manifestType !== ManifestType.ApiSpec) {
242+
value = (await expandFileFunctionMacros(content, true, options)).content;
243+
value = expandEnvironmentVariable(value, options.envs);
244+
} else {
245+
value = expandEnvironmentVariable(value, options.envs);
246+
}
247+
248+
const notExpandedVars = getEnvironmentVariables(value);
249+
if (notExpandedVars.length > 0) {
250+
throw new MissingEnvironmentVariablesError(notExpandedVars.join(","));
251+
}
252+
return value;
253+
}

0 commit comments

Comments
 (0)