inspector/core/mcp/uriTemplate.ts
cliffhall 6acaff70ca fix: honor a defined-but-empty value, and announce the expansion error
Addresses Copilot's round-8 review on #2035 (one inline comment, one
suppressed). Both real.

1. RFC 6570 distinguishes an UNDEFINED variable, which omits its whole
   expression, from one defined as the empty string, which expands. The
   expander was calling `definedValues` on the way in, collapsing the two --
   so `x{?q}` with `{ q: "" }` gave `x` instead of `x?q=`, and there was no
   way for any caller, `readResourceFromTemplate` included, to request
   either that or `x;q`. Key presence is now honored, including §3.2.7's
   rule that the matrix operator drops the `=` for an empty value (`x{;q}`
   -> `x;q`, while `?`/`&` keep it).

   Dropping blanks is a FORM concern, so it moved to the two form
   boundaries: the web panel seeds every declared variable with "" and the
   TUI's ink-form returns "" for every untouched field, so neither can tell
   "never touched" from "deliberately empty" -- but that is a fact about a
   text input, not about the template, and it has no business deciding what
   a template means for every other caller.

   Six unit tests and one integration test encoded the old collapse and now
   assert the corrected meaning. The prototype-collision cases get stronger
   rather than weaker: a blank `{?toString}` expands to `?toString=` -- its
   OWN empty value -- where the original bug expanded the inherited
   function's body.

2. The expansion error is now `role="alert"`. It can appear while typing (a
   pasted value that cannot be encoded), and plain text is not announced, so
   a screen-reader user met a disabled Read Resource with no stated reason.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 23:08:30 -04:00

630 lines
26 KiB
TypeScript

/**
* RFC 6570 URI Template parsing and expansion, shared by every client (#1919).
*
* This lives in `core/` rather than in a client so the web Resources form and
* the TUI cannot disagree about what a template means. Both derive their form
* fields from {@link templateVariables} and expand through
* {@link expandUriTemplate} — the web panel directly, the TUI via
* `InspectorClient.readResourceFromTemplate`.
*
* The **CLI is deliberately not a consumer**: it has no template form, and its
* `resources/read` passes the already-expanded `--uri` straight to
* `readResource` (see `clients/cli/src/handlers/run-method.ts`). Nothing here
* runs for it.
*
* ## Why this is not simply `new UriTemplate(t).expand(v)`
*
* The SDK's `UriTemplate` is still used, but only to *validate* a template —
* constructing it is what rejects an unclosed expression. Its expander is not,
* because it is incomplete in ways a form makes visible: a form has to *name*
* the variables it asks the user to fill in, so a parser that mangles a name
* produces a field nobody can use. Each of these was measured against the
* pinned SDK, not inferred:
*
* | Shape | SDK behavior |
* | ---------------- | --------------------------------------------------------- |
* | `{a,b}` | raw-joins the values — no encoding, operator prefix dropped |
* | `{;id}` | `;` is not in its operator list, so the variable is `;id` |
* | `{id:3}` | the prefix modifier is folded into the name, giving `id:3` |
* | `{+v}` / `{#v}` | `encodeURI` mangles reserved `[`/`]` and double-encodes `%` |
* | `{v}` | `encodeURIComponent` leaves the sub-delims `!'()*` bare |
*
* An earlier revision delegated the shapes the SDK got right and took over only
* the rest. That split is gone: once the encoders themselves differed, the two
* paths would have encoded the *same value* differently depending on whether
* the expression happened to carry a modifier. One expander, one set of rules.
*/
import { UriTemplate } from "@modelcontextprotocol/client";
/**
* The RFC 6570 operators.
*
* `;` is here but **not** in the SDK's own list, which is why `{;id}` parses
* there as a variable literally named `;id`. Order matters only in that each is
* a distinct single character; the first match wins.
*/
const OPERATORS = ["+", "#", ".", "/", ";", "?", "&"] as const;
/**
* Operators whose expansion omits cleanly when the variables in it are
* undefined — the whole expression, separator and all, simply disappears and
* what is left is still a well-formed URI naming a real (broader) resource.
*
* The remaining operators — `""` (simple) and `+` (reserved) — interpolate the
* value into the *middle* of the URI, so omitting one leaves an empty path
* segment rather than a shorter URI: measured against the pinned SDK,
* `file:///users/{userId}/profile` expands to `file:///users//profile` and
* `x://a/{+path}` to `x://a/`, both of which name a different resource. Those
* are the ones a form must require before allowing a read.
*
* `#` is in the omittable set on the same measurement: `x://a{#frag}` with no
* `frag` expands to exactly `x://a`. A fragment is optional by construction.
*/
const OMITTABLE_OPERATORS = new Set(["#", ".", "/", ";", "?", "&"]);
/** Operators that expand to `name=value` pairs rather than bare values. */
const NAMED_OPERATORS = new Set([";", "?", "&"]);
/** A single variable reference inside an expression, e.g. `id` or `id:3`. */
export interface VarSpec {
name: string;
/**
* The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*.
* Applied to the value before percent-encoding, per §3.2.1.
*/
maxLength?: number;
}
interface TemplateLiteral {
kind: "literal";
text: string;
}
interface TemplateExpression {
kind: "expression";
/** The expression including its braces, e.g. `{?topic}`. */
source: string;
/** The RFC 6570 operator, or `""` for a simple expression. */
operator: string;
/** The variable references, in order. */
varspecs: VarSpec[];
/** Bare variable names, `*` and any `:length` modifier stripped. */
names: string[];
/**
* True when a varspec is not one RFC 6570 admits — an out-of-grammar modifier
* (`{id:abc}`) or an empty member (`{}`, `{a,}`); see {@link parseVarSpec}.
* Strict expansion rejects such a template and the form withholds the read,
* while *discovery* stays lenient so the panel still renders whatever names
* the template does declare rather than going blank.
*/
invalid: boolean;
}
export type TemplatePart = TemplateLiteral | TemplateExpression;
export interface TemplateVariable {
name: string;
/** The operator of the expression the variable was first seen in. */
operator: string;
/**
* True when this variable appears in at least one expression that cannot be
* omitted without changing the URI's structure — see
* {@link OMITTABLE_OPERATORS}. Drives the form's "Optional" marker.
*
* It does **not** mean "the user must fill this field in". Requiredness is a
* property of the *expression*: RFC 6570 drops undefined names from a
* multi-name expression, so `{a,b}` with only `a` filled expands to `a`'s
* value. Gate submission on {@link hasRequiredValues} over
* {@link requiredGroups}, never by testing this flag per variable.
*/
required: boolean;
}
/**
* One character of a variable's name.
*
* RFC 6570's `varchar` is `ALPHA / DIGIT / "_" / pct-encoded`. This admits two
* more — `-` and `~` — and the widening is deliberate, on a rule the RFC's own
* set is a subset of: **a name is the one thing expansion emits into the URI
* without encoding it** (the `;`, `?` and `&` operators write `name=value`), so
* a name must consist of characters that need no encoding. That is RFC 3986's
* *unreserved* set — `ALPHA / DIGIT / -._~` — plus pct-encoded.
*
* Strict `varchar` would refuse `{user-id}`, which real servers publish and
* which the SDK's own matcher round-trips (`match()` compiles the hyphen
* literally). Refusing to read a resource that demonstrably works is a worse
* outcome for a debugging tool than tolerating a name one character outside the
* production — whereas everything this *does* reject is a shape we would
* otherwise have to **guess** at, which is the line the rest of this module
* draws too.
*/
const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`;
/**
* RFC 6570's `varspec`: a `varname` with at most one trailing modifier, either
* the explode `*` or a `:max-length`, never both.
*
* `varname = varchar *( ["."] varchar )`, so a `.` may separate name segments
* but cannot lead, trail, or double. `max-length` is `%x31-39 0*3DIGIT` — 1 to
* 9999, no leading zero — so `{id:}`, `{id:0}`, `{id:abc}` and `{id:10000}` are
* invalid *templates*, not templates with an ignorable modifier.
*
* Anchoring the whole varspec is what rejects the shapes a looser "strip the
* `*`, split on `:`" pass waves through. Measured against this branch before
* the change, each expanded rather than being refused:
*
* | Varspec | Was | Why it is invalid |
* | --------- | -------------------- | ----------------------------------- |
* | `{*id}` | name `id` | explode is a *trailing* modifier |
* | `{id*:3}` | name `id`, truncated | the two modifiers are exclusive |
* | `{ id }` | name `id` | whitespace is not a `varchar` |
* | `{a b}` | name `a b` | ditto — and it emits into the URI |
*/
const VARSPEC_GRAMMAR = new RegExp(
`^(${VARCHAR}(?:\\.?${VARCHAR})*)(?:\\*|:([1-9][0-9]{0,3}))?$`,
);
/**
* Parses one varspec (`id`, `id*`, `id:3`) into a name and optional prefix,
* or `"invalid"` for anything {@link VARSPEC_GRAMMAR} does not admit.
*
* Three classes reach that verdict, none of them ignorable:
*
* - **A modifier outside the grammar** (`{id:abc}`), or two at once (`{id*:3}`),
* or one in the wrong position (`{*id}`). Treating any of them as a plain
* `{id}` would send a URI that does not match what the server advertised,
* with nothing to alert anyone — and `{id*:3}` silently *truncated* the value.
* - **A name outside the character set** (`{a b}`, `{a/b}`). A named operator
* emits the name verbatim, so this is the one place an unencodable character
* reaches the URI as structure.
* - **An empty varspec** — `{}`, `{,}`, `{a,}`, `{?}`, `{*}`. RFC 6570 requires
* at least one varspec per expression and admits no empty member, so this is
* a malformed *template*, not an expression with a member to skip. Skipping
* it is the more dangerous reading: `x://{}` would expand to `x://` while the
* form rendered no inputs, so its "everything required is filled" check would
* be vacuously true and it would submit a URI that is not the template the
* server published.
*
* The explode modifier parses but is not honored: it only changes how a list or
* map value is joined, and every value reaching this module is a single string.
*/
function parseVarSpec(raw: string): VarSpec | "invalid" {
const match = VARSPEC_GRAMMAR.exec(raw);
if (match === null) return "invalid";
const [, name, maxLength] = match;
return maxLength === undefined ? { name } : { name, maxLength: +maxLength };
}
/**
* Splits a template into literal runs and expressions.
*
* An unclosed `{` yields a trailing literal, which is what makes the callers
* below degrade to "render no inputs, show the template verbatim" rather than
* throw at the user.
*/
export function parseUriTemplate(uriTemplate: string): TemplatePart[] {
const parts: TemplatePart[] = [];
let literal = "";
let i = 0;
while (i < uriTemplate.length) {
if (uriTemplate[i] !== "{") {
literal += uriTemplate[i];
i += 1;
continue;
}
const end = uriTemplate.indexOf("}", i);
if (end === -1) {
// Unclosed expression — the rest is not a template, treat it as text.
literal += uriTemplate.slice(i);
break;
}
if (literal) {
parts.push({ kind: "literal", text: literal });
literal = "";
}
const body = uriTemplate.slice(i + 1, end);
const operator = OPERATORS.find((op) => body.startsWith(op)) ?? "";
const parsed = body.slice(operator.length).split(",").map(parseVarSpec);
const varspecs = parsed.filter(
(spec): spec is VarSpec => spec !== "invalid",
);
parts.push({
kind: "expression",
source: uriTemplate.slice(i, end + 1),
operator,
varspecs,
names: varspecs.map((spec) => spec.name),
invalid: parsed.includes("invalid"),
});
i = end + 1;
}
if (literal) parts.push({ kind: "literal", text: literal });
return parts;
}
/**
* The variables a form should render an input for, in template order and
* deduplicated by name. A name appearing under more than one operator is
* required if *any* of its occurrences is.
*/
export function templateVariables(uriTemplate: string): TemplateVariable[] {
const byName = new Map<string, TemplateVariable>();
for (const part of parseUriTemplate(uriTemplate)) {
if (part.kind !== "expression") continue;
const required = !OMITTABLE_OPERATORS.has(part.operator);
for (const name of part.names) {
const existing = byName.get(name);
if (existing) {
existing.required = existing.required || required;
} else {
byName.set(name, { name, operator: part.operator, required });
}
}
}
return [...byName.values()];
}
/**
* The variable names of each expression that cannot be omitted, in template
* order — one entry per expression, not per variable.
*
* This is deliberately *not* folded onto {@link TemplateVariable}. A name can
* appear in several expressions with different operators, and each required
* expression has to be satisfied on its own: in `x{?a}{?b}{a,b}` the only
* required expression is `{a,b}`, which either `a` or `b` satisfies (the SDK
* expands that template with just `a` to `x?a=11`), while a per-variable model
* that kept only the first occurrence's group would mark both names required
* with singleton groups and refuse it. And in `{a,b}{a,c}` — two required
* expressions sharing `a` — filling `b` and `c` satisfies both, which no
* per-variable flag can express at all.
*/
export function requiredGroups(uriTemplate: string): string[][] {
const groups: string[][] = [];
for (const part of parseUriTemplate(uriTemplate)) {
if (part.kind !== "expression") continue;
if (OMITTABLE_OPERATORS.has(part.operator)) continue;
// An expression naming no variable at all (`x://{}`) is a malformed
// template, which {@link tryExpandUriTemplate} refuses with that as the
// reason. Emitting an empty group here too would gate the form a second
// time on a condition nothing can satisfy, and the "any one of" message
// built from it would name no fields.
if (part.names.length === 0) continue;
groups.push(part.names);
}
return groups;
}
/**
* The required expressions `values` does **not** satisfy — none of their names
* filled in — in template order.
*
* Exported so a caller that has to *name* the missing fields derives them from
* the same pass that decides whether anything is missing at all. The TUI built
* its "Missing required template variable(s): …" list with its own bare
* `values[name]` filter, which for a variable legitimately named `constructor`
* or `toString` found `Object.prototype`'s member and judged the group
* satisfied — so {@link hasRequiredValues} blocked the submit while the message
* listed nothing.
*/
export function unmetRequiredGroups(
groups: string[][],
values: Record<string, string>,
): string[][] {
return groups.filter(
(names) =>
!names.some((name) => (readValue(values, name) ?? "").length > 0),
);
}
/**
* Whether `values` supplies everything expansion structurally needs: every
* required expression has at least one of its names filled in.
*/
export function hasRequiredValues(
groups: string[][],
values: Record<string, string>,
): boolean {
return unmetRequiredGroups(groups, values).length === 0;
}
/**
* Reads a variable, ignoring anything inherited from `Object.prototype`.
*
* `toString`, `constructor`, `valueOf` and `__proto__` are all valid RFC 6570
* variable names (`varname` allows ALPHA / DIGIT / `_` / pct-encoded), and a
* plain object lookup finds the prototype's member for every one of them. A
* bare `values[name] !== undefined` therefore reports a *blank* `{?toString}`
* as supplied and expands a function body into the URI; `hasRequiredValues`
* likewise saw `constructor` as satisfied because `Object.length` is 1.
*/
function readValue(
values: Record<string, string>,
name: string,
): string | undefined {
return Object.hasOwn(values, name) ? values[name] : undefined;
}
/**
* Drops empty entries so an untouched optional field reads as *undefined*
* (the expression disappears) rather than as the empty string (which would
* expand to a valueless `?topic=`).
*
* **A UI-boundary concern, deliberately not part of the expander.** RFC 6570
* distinguishes an undefined variable (the expression is omitted) from one
* defined as `""` (it expands — `x{?q}` gives `x?q=`, `x{;q}` gives `x;q`), and
* collapsing the two inside {@link expandUriTemplateStrict} made those URIs
* unrequestable through any caller, `readResourceFromTemplate` included.
*
* What forces the collapse is a *form*: both clients seed every declared
* variable with `""`, and a text input cannot express "defined but empty", so
* an untouched field is indistinguishable from a deliberately empty one. That
* is a property of the form, not of the template — so each form applies this
* on its way in, and the expander honors exactly what it is handed.
*/
export function definedValues(
values: Record<string, string>,
): Record<string, string> {
return Object.fromEntries(
Object.entries(values).filter(([, value]) => value.length > 0),
);
}
/**
* The characters RFC 6570 leaves alone under the `+` and `#` operators:
* RFC 3986 *unreserved* plus *reserved* (gen-delims and sub-delims).
*/
const ALLOW_RESERVED = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/gu;
/**
* The `allow-reserved` value encoding of RFC 6570 §3.2.1, used by `+` and `#`.
*
* The SDK reaches for `encodeURI` here, which is close but wrong twice over,
* and both cases corrupt the URI rather than merely over-escaping it:
*
* - It escapes `[` and `]`, which are *reserved* and must survive — so an IPv6
* literal `[::1]` becomes `%5B::1%5D`.
* - It escapes `%`, so an already-encoded value is double-encoded: `%2F`
* becomes `%252F`, and the server sees a literal "%2F" rather than a slash.
*
* The spec instead keeps existing pct-triplets intact, which is what the split
* below does: odd chunks are whole `%XX` triplets and pass through untouched,
* even chunks are scanned for anything outside the allowed set. A lone `%` is
* not a triplet, so it lands in an even chunk and is correctly encoded to
* `%25`. The `u` flag makes the class match by code point, so an astral
* character is handed to `encodeURIComponent` whole rather than as surrogates.
*/
function encodeAllowReserved(value: string): string {
return value
.split(/(%[0-9A-Fa-f]{2})/g)
.map((chunk, index) =>
index % 2 === 1
? chunk
: chunk.replace(ALLOW_RESERVED, (char) => encodeURIComponent(char)),
)
.join("");
}
/**
* Percent-encodes everything outside RFC 3986's *unreserved* set, which is what
* every operator except `+` and `#` calls for.
*
* `encodeURIComponent` alone is not that set: it leaves `!`, `'`, `(`, `)` and
* `*` unescaped. Those are sub-delims, not unreserved, so RFC 6570 requires
* them encoded for simple, label, path, matrix and query expansion. They are
* substituted afterwards rather than hand-rolled, so `encodeURIComponent` still
* does the UTF-8 work for everything else.
*/
const FORCE_ENCODED: Record<string, string> = {
"!": "%21",
"'": "%27",
"(": "%28",
")": "%29",
"*": "%2A",
};
function encodeUnreserved(value: string): string {
return encodeURIComponent(value).replace(
/[!'()*]/g,
(char) => FORCE_ENCODED[char],
);
}
/** Encodes one value for its operator: reserved characters survive `+` and `#`. */
function encodeValue(value: string, operator: string): string {
return operator === "+" || operator === "#"
? encodeAllowReserved(value)
: encodeUnreserved(value);
}
/**
* Applies a prefix modifier, then encodes.
*
* Truncation is by *code point* (`Array.from`), not by `slice`: RFC 6570 counts
* the prefix in characters, and `String.prototype.slice` counts UTF-16 code
* units, so it can cut an astral character in half and yield a lone surrogate.
*/
function renderValue(value: string, spec: VarSpec, operator: string): string {
const truncated =
spec.maxLength === undefined
? value
: Array.from(value).slice(0, spec.maxLength).join("");
return encodeValue(truncated, operator);
}
/** Expands one expression per RFC 6570. Returns "" if no name has a value. */
function expandExpression(
part: TemplateExpression,
values: Record<string, string>,
): string {
// The value is carried through the filter rather than re-read afterwards, so
// the "is it defined" test and the read cannot disagree — and so nothing
// downstream needs a non-null assertion to convince the compiler.
const present = part.varspecs.flatMap((spec) => {
const value = readValue(values, spec.name);
return value === undefined ? [] : [{ spec, value }];
});
if (present.length === 0) return "";
const { operator } = part;
if (NAMED_OPERATORS.has(operator)) {
const pairs = present.map(({ spec, value }) => {
const rendered = renderValue(value, spec, operator);
// RFC 6570 §3.2.7: the matrix operator drops the `=` for an empty value,
// so `x{;q}` with `q = ""` is `x;q` — while `?`/`&` keep it (`x?q=`).
// Both are only reachable now that the expander honors a defined-but-
// empty value instead of collapsing it into "undefined"; see
// `definedValues` for where that collapse legitimately happens instead.
return operator === ";" && rendered.length === 0
? spec.name
: `${spec.name}=${rendered}`;
});
// `;` repeats its separator per pair; `?`/`&` join with `&`.
return operator === ";"
? `;${pairs.join(";")}`
: `${operator}${pairs.join("&")}`;
}
const rendered = present.map(({ spec, value }) =>
renderValue(value, spec, operator),
);
switch (operator) {
case "#":
return `#${rendered.join(",")}`;
case ".":
return `.${rendered.join(".")}`;
case "/":
return `/${rendered.join("/")}`;
// "" and "+" — a bare comma-joined list, no prefix.
default:
return rendered.join(",");
}
}
/**
* Expands a whole parsed template.
*
* Each expression is expanded **independently**, which is what RFC 6570
* specifies — expansion carries no cross-expression state. The SDK instead
* tracks whether a query expression has already emitted and rewrites a later
* `{?two}`'s leading `?` to `&`. That looks friendlier and is wrong: measured
* against the pinned SDK, `x{?one}{?two}` expands to `x?one=1&two=2`, and
* `UriTemplate.match` on that same template *rejects* it — `match("x?one=1&two=2")`
* is `null`, while `match("x?one=1?two=2")` returns both variables. So the
* rewrite emits a URI the advertised template cannot match, which is precisely
* the failure #1919 is about, one level up.
*
* A server that wants a continuation advertises it: `{?one}{&two}` expands to
* `x?one=1&two=2` and matches. Producing that shape is the server's choice to
* declare, not ours to infer.
*/
export function expandParts(
parts: TemplatePart[],
values: Record<string, string>,
): string {
return parts
.map((part) =>
part.kind === "literal" ? part.text : expandExpression(part, values),
)
.join("");
}
/**
* Expands a template against the supplied values per RFC 6570 — percent-encoding
* each value according to its operator, and omitting an expression none of
* whose variables are defined. **Throws** on a template that is not valid.
*
* A key present with an empty string is *defined*, and expands: `x{?q}` gives
* `x?q=` and `x{;q}` gives `x;q`. Only an absent key omits its expression. A
* form that cannot tell "untouched" from "deliberately empty" drops its blanks
* with {@link definedValues} before calling — which is where that judgement
* belongs, since it is a fact about the form rather than about the template.
*
* Every expression is expanded by {@link expandParts}; the SDK's `UriTemplate`
* is constructed only because that is what rejects an unclosed expression, and
* callers such as `readResourceFromTemplate` wrap the error it throws. Its own
* `expand` is deliberately unused — see the module comment for the five shapes
* it gets wrong.
*
* Its constructor is not a complete validator either: it accepts `{id:abc}`,
* whose modifier is not RFC 6570's `max-length` production. Treating that as a
* plain `{id}` would send a URI the server never advertised, so the owned
* parser's verdict is checked too.
*/
export function expandUriTemplateStrict(
uriTemplate: string,
values: Record<string, string>,
): string {
new UriTemplate(uriTemplate);
const parts = parseUriTemplate(uriTemplate);
const bad = parts.find((part) => part.kind === "expression" && part.invalid);
if (bad) {
throw new Error(
`Invalid RFC 6570 varspec in "${uriTemplate}": ${
bad.kind === "expression" ? bad.source : ""
} — each varspec must name a variable, optionally with an explode (*) or a 1-9999 prefix modifier.`,
);
}
return expandParts(parts, values);
}
/**
* The outcome of an expansion: the URI, or the reason there isn't one.
*
* This is what a form gates its submit on. The lenient
* {@link expandUriTemplate} below is for *display* — it answers with the raw
* template so a panel can keep rendering — and a caller that mistook that
* fallback for a URI would issue a `resources/read` for the template itself,
* literal braces and all. Making the failure a value rather than a string the
* caller has to recognize is what keeps that from being possible.
*
* Failure is not only the malformed-template case: a value carrying an
* unpaired surrogate has no UTF-8 encoding, so `encodeURIComponent` throws
* `URIError` on it, and a text input can hold one via paste.
*/
export type TemplateExpansion =
| { uri: string; error?: undefined }
| { uri?: undefined; error: string };
/**
* {@link expandUriTemplateStrict} with the throw turned into a value, for
* callers that must *decide* rather than degrade — chiefly the web panel, which
* disables Read Resource and shows the reason instead of sending a URI it knows
* is wrong.
*/
export function tryExpandUriTemplate(
uriTemplate: string,
values: Record<string, string>,
): TemplateExpansion {
try {
return { uri: expandUriTemplateStrict(uriTemplate, values) };
} catch (error) {
return { error: error instanceof Error ? error.message : String(error) };
}
}
/**
* {@link expandUriTemplateStrict}, but falling back to the raw template string
* instead of throwing.
*
* This is the **display** variant, and only that: it runs during render (the
* URI preview), where a throw would take out the whole panel, and the raw
* template is the honest thing to show for a template nothing can expand.
*
* Do **not** submit what it returns. A caller deciding whether to issue a read
* uses {@link tryExpandUriTemplate}, whose failure is a value it cannot
* mistake for a URI; `readResourceFromTemplate` uses the strict variant and
* wraps the error with the template's name.
*/
export function expandUriTemplate(
uriTemplate: string,
values: Record<string, string>,
): string {
return tryExpandUriTemplate(uriTemplate, values).uri ?? uriTemplate;
}