Skip to content

Commit c57b031

Browse files
committed
feat(manifest): add host-agnostic manifest template resolver
Extract the DriverContext-free $[file('...')] and ${{env}} resolution logic into @microsoft/app-manifest so it can be consumed without fx-core's DriverContext, i18n, or UserError dependencies. Exports resolveManifestTemplate, expandFileFunctionMacros, resolveManifestFunctionCall, expandEnvironmentVariable, getEnvironmentVariables, the ManifestType enum, and typed error classes carrying structured data fields. Uses only node:path and node:fs/promises (no new dependencies). Adds dedicated unit tests exercising file inlining, JSON escaping, BOM/CRLF normalization, nested file() calls, env-as-parameter, ApiSpec skip, and every typed error path.
1 parent cb22bf8 commit c57b031

3 files changed

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

0 commit comments

Comments
 (0)