From 2e06b369f4436ff484f322e74895293f7ac49326 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Sun, 16 Aug 2026 23:44:45 -0400 Subject: [PATCH] fix: encode literals per RFC 6570 3.1, and label the varname tolerance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot's round-10 review on #2035. One inline comment, one suppressed; both real. 1. Literals were copied through verbatim, but RFC 6570 3.1 requires them pct-encoded on expansion -- the conformance case `café/{var}` expands to `caf%C3%A9/value`. Measured on ef3f5e74 it produced `café/value`, raw UTF-8 in the path; since this module replaced the SDK's expander for both clients, nothing else was going to encode it. (The pinned SDK does not either -- it returns `café/value` too -- so this is a gap we inherited rather than introduced.) `encodeLiteral` keeps reserved delimiters (a literal's `/`, `?` and `#` are structure) and existing pct-triplets, and encodes the rest. A BRACE passes through: it can only reach a literal from a malformed template, where an unclosed `{` becomes trailing text, and `%7B` would leave a string resembling neither the template nor anything the server can match. This is also what removed the preview's placeholder machinery. No token can survive a round trip now that literals are encoded, so the preview assembles part by part instead -- `encodeLiteral` for literal runs, `expandTemplateExpression` for filled expressions, `part.source` for the rest. Both halves still come from the shared expander, so the preview cannot promise a URI that submitting would not send, and the question of what token could never collide with a server-supplied template is gone. 2. On the varname set I took the structural point rather than the conclusion. `VARCHAR` is RFC-exact again; `-` and `~` moved into a separately named `TOLERATED_VARCHAR` tier, and a name needing it parses with `conforming: false`, surfaced on `TemplateVariable`. So the grammar in the code says what the RFC says and the tolerance is explicit and separately tested -- but the Inspector still expands `{default-graph-uri}`, because real servers publish hyphenated names and the SDK's matcher round-trips them. Refusing to read a resource that demonstrably works is a worse failure for a debugging tool than expanding a name the conformance suite would reject. A caller wanting RFC-exact behavior refuses on the flag rather than re-deriving the grammar. npm run ci passes. Signed-off-by: cliffhall --- README.md | 2 + .../web/src/test/core/mcp/uriTemplate.test.ts | 68 ++++++- clients/web/src/utils/uriTemplate.test.ts | 20 ++ clients/web/src/utils/uriTemplate.ts | 59 +++--- core/mcp/uriTemplate.ts | 172 ++++++++++++------ 5 files changed, 224 insertions(+), 97 deletions(-) diff --git a/README.md b/README.md index 3286f2d9..62514114 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ The `;` and `:3` rows are the ones a user sees directly: on the SDK's parse the A template that cannot be expanded at all — an out-of-grammar modifier (`{id:abc}`), or an expression declaring no variable (`{}`, `{a,}`, `{?}`) — **withholds the read** rather than sending something. Pick **events_malformed** (`foobar://events/{topic:abc}`) to see it: Read Resource is disabled, the reason is printed under the form, and the preview shows the template as the server declared it. The alternative is worse than it looks: `x://{}` would otherwise expand to `x://` with no inputs rendered, so the form's "everything required is filled" check passes vacuously and it reads a URI that is not the template the server published. +Literals are pct-encoded on expansion too (RFC 6570 §3.1): `café/{var}` sends `caf%C3%A9/value`, not raw UTF-8 in the path — something the SDK's expander does not do either. And the *names* a template may use are RFC 6570's `varchar` plus a labelled tolerance for `-` and `~`: the conformance suite rejects `{default-graph-uri}`, but real servers publish such names and the SDK's matcher round-trips them, so the Inspector expands them and marks the variable `conforming: false` rather than refusing a resource that demonstrably works. + An **undefined** variable is what omits its expression — a variable defined as the empty string expands (`x{?q}` gives `x?q=`, `x{;q}` gives `x;q`, per RFC 6570 §3.2.7). The expander honors that distinction, so a caller such as `readResourceFromTemplate` can request either URI. Collapsing the two is a *form* concern, not a template one: both clients seed every declared variable with `""` and a text input cannot express "defined but empty", so each form drops its blanks (`definedValues`) on the way in. Requiredness is a property of the **expression**, not the variable: RFC 6570 drops undefined names from a multi-name expression, so `{a,b}` with only `a` filled is expandable and a form must not block it. `requiredGroups` returns one entry per non-omittable expression and `hasRequiredValues` asks that each be satisfied by any one of its names — which no per-variable flag can express once a name recurs across expressions (`{a,b}{a,c}` is satisfied by filling `b` and `c`). diff --git a/clients/web/src/test/core/mcp/uriTemplate.test.ts b/clients/web/src/test/core/mcp/uriTemplate.test.ts index 57b18154..b249d638 100644 --- a/clients/web/src/test/core/mcp/uriTemplate.test.ts +++ b/clients/web/src/test/core/mcp/uriTemplate.test.ts @@ -54,13 +54,13 @@ describe("parseUriTemplate", () => { describe("templateVariables", () => { it("finds a simple variable and marks it required", () => { expect(templateVariables("foobar://events/{topic}")).toEqual([ - { name: "topic", operator: "", required: true }, + { name: "topic", operator: "", required: true, conforming: true }, ]); }); it("finds a query variable the old `\\{(\\w+)\\}` regex could not see", () => { expect(templateVariables("foobar://events{?topic}")).toEqual([ - { name: "topic", operator: "?", required: false }, + { name: "topic", operator: "?", required: false, conforming: true }, ]); }); @@ -82,7 +82,7 @@ describe("templateVariables", () => { it("deduplicates a repeated name and keeps it required if any use is", () => { expect(templateVariables("x://{?id}/{id}")).toEqual([ - { name: "id", operator: "?", required: true }, + { name: "id", operator: "?", required: true, conforming: true }, ]); }); @@ -232,7 +232,7 @@ describe("varspec modifiers", () => { // user cannot usefully fill. it("parses a prefix modifier off the variable name", () => { expect(templateVariables("x://a/{id:3}")).toEqual([ - { name: "id", operator: "", required: true }, + { name: "id", operator: "", required: true, conforming: true }, ]); }); @@ -266,7 +266,7 @@ describe("the ; (path-parameter) operator", () => { // variable named ";id" and expands to "". it("is recognised as an operator, not part of the name", () => { expect(templateVariables("x://a{;id}")).toEqual([ - { name: "id", operator: ";", required: false }, + { name: "id", operator: ";", required: false, conforming: true }, ]); }); @@ -641,6 +641,64 @@ describe("unmetRequiredGroups", () => { }); }); +describe("literal normalization", () => { + // RFC 6570 3.1: a literal may contain non-ASCII, but expansion emits it + // pct-encoded -- the conformance case `café/{var}` expands to + // `caf%C3%A9/value`. Literals used to pass through verbatim, and since this + // module replaced the SDK's expander for both clients, nothing else was + // going to encode them. (Measured: the pinned SDK returns `café/value` too.) + it("percent-encodes a non-ASCII literal", () => { + expect(expandUriTemplate("café/{var}", { var: "value" })).toBe( + "caf%C3%A9/value", + ); + }); + + it("encodes a space in a literal", () => { + expect(expandUriTemplate("a b/{v}", { v: "x" })).toBe("a%20b/x"); + }); + + it.each([ + ["URI delimiters", "x://a/b?c=d#e{?q}", { q: "1" }, "x://a/b?c=d#e?q=1"], + ["an existing pct-triplet", "a%20b/{v}", { v: "x" }, "a%20b/x"], + ])("preserves %s", (_label, template, values, expected) => { + expect(expandUriTemplate(template, values)).toBe(expected); + }); + + it("passes a brace through, so a malformed template stays legible", () => { + // A brace reaches a literal only from a malformed template (an unclosed + // `{` becomes trailing text). Encoding it to %7B would leave a string + // resembling neither the template nor anything the server could match. + expect(expandUriTemplate("x://a/{oops", { oops: "v" })).toBe("x://a/{oops"); + }); +}); + +describe("names outside RFC 6570 varchar", () => { + // The conformance suite rejects `{~thing}` and `{default-graph-uri}`. The + // Inspector expands them anyway -- real servers publish hyphenated names and + // the SDK's matcher round-trips them -- but the tolerance is labelled rather + // than folded into the grammar, so a caller wanting RFC-exact behavior can + // refuse on the flag. + it.each(["user-id", "a~b"])("expands the tolerated name %s", (name) => { + expect(expandUriTemplate(`x://{${name}}`, { [name]: "7" })).toBe("x://7"); + }); + + it.each([ + ["default-graph-uri", false], + ["~thing", false], + ["plain_name", true], + ["a.b", true], + ["%61%62", true], + ])("marks %s conforming=%s", (name, conforming) => { + expect(templateVariables(`x://{${name}}`)[0].conforming).toBe(conforming); + }); + + it("marks a name non-conforming if any occurrence needed the tolerance", () => { + expect(templateVariables("x://{user-id}{?user-id}")[0].conforming).toBe( + false, + ); + }); +}); + describe("prefix modifier truncation", () => { // RFC 6570 2.4.1 counts characters, and says a pct-encoded triplet counts as // ONE. Truncating by code point cut the sixth triplet in half -- measured diff --git a/clients/web/src/utils/uriTemplate.test.ts b/clients/web/src/utils/uriTemplate.test.ts index 3e9ca93b..f25c9e73 100644 --- a/clients/web/src/utils/uriTemplate.test.ts +++ b/clients/web/src/utils/uriTemplate.test.ts @@ -79,3 +79,23 @@ describe("preview with Object.prototype-colliding names", () => { ); }); }); + +describe("previewUriTemplate - literals", () => { + // The preview promises the URI that submitting would send, so it applies the + // same RFC 6570 3.1 literal encoding the wire does. + it("percent-encodes a non-ASCII literal", () => { + expect(previewUriTemplate("café/{var}", { var: "value" })).toBe( + "caf%C3%A9/value", + ); + }); + + it("encodes the literal even while an expression is still unfilled", () => { + expect(previewUriTemplate("café/{var}", {})).toBe("caf%C3%A9/{var}"); + }); + + it("keeps a malformed template legible rather than encoding its brace", () => { + expect(previewUriTemplate("x://café/{oops", {})).toBe( + "x://caf%C3%A9/{oops", + ); + }); +}); diff --git a/clients/web/src/utils/uriTemplate.ts b/clients/web/src/utils/uriTemplate.ts index 941ef23c..78db1bc9 100644 --- a/clients/web/src/utils/uriTemplate.ts +++ b/clients/web/src/utils/uriTemplate.ts @@ -12,12 +12,14 @@ import { definedValues, - expandUriTemplate, + encodeLiteral, + expandTemplateExpression, parseUriTemplate, } from "@inspector/core/mcp/uriTemplate.js"; export { definedValues, + encodeLiteral, expandUriTemplate, hasRequiredValues, parseUriTemplate, @@ -33,56 +35,37 @@ export type { } from "@inspector/core/mcp/uriTemplate.js"; /** - * A placeholder standing in for an expression the user has not filled in yet. + * A partially-expanded template for display: an expression with at least one + * value is expanded exactly as the wire would render it, and the rest are left + * standing as written so the user can see what is still needed. * - * `U+0000` cannot appear in a URI template, so a token built from it can never - * collide with real template text; and because it is emitted as *literal* text - * rather than as a variable value, expansion passes it through unencoded. - */ -const deferredToken = (index: number) => `\u0000${index}\u0000`; - -/** - * A partially-expanded template for display: expressions with at least one - * value are expanded exactly as `expandUriTemplate` would, and the rest are - * left standing as written so the user can see what is still needed. + * Assembled part by part rather than by rewriting the template and re-parsing + * it. The rewrite approach needed a placeholder to stand in for each unexpanded + * expression, and a placeholder is exactly what cannot survive the round trip + * now that literals are pct-encoded on expansion (RFC 6570 §3.1) -- whatever + * token stood in for `{?topic}` would come back encoded and no longer match. + * Going part by part removes the token, and with it the question of what text + * could never collide with a server-supplied template. * - * Unfilled expressions are swapped for an inert token and restored afterwards, - * and the rewritten template is expanded by `expandUriTemplate` itself. Routing - * it back through the real expander is what keeps the preview honest: it cannot - * promise a URI that submitting would not send. (Expansion carries no - * cross-expression state — see `expandParts` — so this is a per-expression - * substitution, not a whole-template rewrite that some later pass depends on.) + * Each half still comes from the shared expander -- `encodeLiteral` for literal + * runs, `expandTemplateExpression` for filled ones -- so the preview cannot + * promise a URI that submitting would not send. */ export function previewUriTemplate( uriTemplate: string, values: Record, ): string { - const parts = parseUriTemplate(uriTemplate); - const deferred: string[] = []; const defined = definedValues(values); - const rewritten = parts + return parseUriTemplate(uriTemplate) .map((part) => { - if (part.kind === "literal") return part.text; + if (part.kind === "literal") return encodeLiteral(part.text); // `Object.hasOwn`, not a bare lookup: `toString` and `constructor` are // valid RFC 6570 variable names, and a plain lookup would find // `Object.prototype`'s member and treat a blank field as filled. - if (part.names.some((name) => Object.hasOwn(defined, name))) { - return part.source; - } - deferred.push(part.source); - return deferredToken(deferred.length - 1); + return part.names.some((name) => Object.hasOwn(defined, name)) + ? expandTemplateExpression(part, defined) + : part.source; }) .join(""); - - const expanded = expandUriTemplate(rewritten, defined); - - // Restored by exact-string replacement rather than by a pattern: a regex - // matching the token would have to embed U+0000 literally, which `eslint` - // rejects (`no-control-regex`) -- and each token's text is already known - // here, so there is nothing to match on. - return deferred.reduce( - (uri, source, index) => uri.replaceAll(deferredToken(index), source), - expanded, - ); } diff --git a/core/mcp/uriTemplate.ts b/core/mcp/uriTemplate.ts index 4fd7fcfd..cbcaffc4 100644 --- a/core/mcp/uriTemplate.ts +++ b/core/mcp/uriTemplate.ts @@ -69,6 +69,14 @@ const NAMED_OPERATORS = new Set([";", "?", "&"]); /** A single variable reference inside an expression, e.g. `id` or `id:3`. */ export interface VarSpec { name: string; + /** + * Absent (i.e. conforming) unless the name needed the `-`/`~` tolerance to + * parse — see {@link TOLERATED_VARCHAR}. Present and `false` marks a name RFC + * 6570's own conformance suite would reject, which the Inspector expands + * anyway because real servers publish such names and the SDK's matcher + * accepts them. + */ + conforming?: false; /** * The RFC 6570 prefix modifier (`{id:3}`), a maximum length in *characters*. * Applied to the value before percent-encoding, per §3.2.1. @@ -107,6 +115,13 @@ export interface TemplateVariable { name: string; /** The operator of the expression the variable was first seen in. */ operator: string; + /** + * `false` when the name is outside RFC 6570's `varchar` and only parsed via + * the documented `-`/`~` tolerance (see {@link TOLERATED_VARCHAR}). A caller + * wanting RFC-exact behavior refuses on this rather than re-deriving the + * grammar; the Inspector itself expands such a template. + */ + conforming: boolean; /** * True when this variable appears in at least one expression that cannot be * omitted without changing the URI's structure — see @@ -122,37 +137,51 @@ export interface TemplateVariable { } /** - * One character of a variable's name. + * RFC 6570's `varchar`, exactly: `ALPHA / DIGIT / "_" / pct-encoded`. * - * 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. + * This is the *conformance* production, kept unwidened so the grammar in the + * code says what the RFC says. What the parser goes on to **accept** is one + * tier wider — see {@link TOLERATED_VARCHAR}. */ -const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`; +const VARCHAR = String.raw`(?:[A-Za-z0-9_]|%[0-9A-Fa-f]{2})`; + +/** + * The two characters accepted in a name beyond `varchar`, as an explicitly + * separate tier rather than a widened grammar. + * + * `-` and `~` are RFC 3986 *unreserved*, so they satisfy the property that + * actually matters at expansion time: a name is the one thing emitted into the + * URI **without** encoding (`;`, `?` and `&` write `name=value`), and these two + * need none. RFC 6570's own conformance suite rejects `{~thing}` and + * `{default-graph-uri}`, so a template using them is non-conforming — but real + * servers publish hyphenated names, and the SDK's matcher round-trips them + * (`match()` compiles the hyphen literally). Refusing to read a resource that + * demonstrably works is a worse failure for a debugging tool than expanding a + * name one character outside the production. + * + * So the tolerance is *labelled* rather than hidden: a varspec matching only + * this tier is parsed with `conforming: false`, surfaced on + * {@link TemplateVariable}, and a caller that wants RFC-exact behavior can + * refuse on that flag without re-implementing the grammar. + */ +const TOLERATED_VARCHAR = String.raw`[-~]`; + +/** `varname = varchar *( ["."] varchar )` — a dot separates, never leads or doubles. */ +function varnameGrammar(varchar: string): string { + return `${varchar}(?:\\.?${varchar})*`; +} /** * 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. + * `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: + * `*`, split on `:`" pass waves through. Measured before it landed, each + * expanded rather than being refused: * * | Varspec | Was | Why it is invalid | * | --------- | -------------------- | ----------------------------------- | @@ -161,39 +190,24 @@ const VARCHAR = String.raw`(?:[A-Za-z0-9_\-~]|%[0-9A-Fa-f]{2})`; * | `{ 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}))?$`, +const MODIFIER = String.raw`(?:\*|:([1-9][0-9]{0,3}))?`; +const CONFORMING_VARSPEC = new RegExp( + `^(${varnameGrammar(VARCHAR)})${MODIFIER}$`, +); +const TOLERATED_VARSPEC = new RegExp( + `^(${varnameGrammar(`(?:${VARCHAR}|${TOLERATED_VARCHAR})`)})${MODIFIER}$`, ); -/** - * 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); + const conforming = CONFORMING_VARSPEC.exec(raw); + const match = conforming ?? TOLERATED_VARSPEC.exec(raw); if (match === null) return "invalid"; const [, name, maxLength] = match; - return maxLength === undefined ? { name } : { name, maxLength: +maxLength }; + return { + name, + ...(maxLength === undefined ? {} : { maxLength: +maxLength }), + ...(conforming === null ? { conforming: false } : {}), + }; } /** @@ -257,11 +271,20 @@ export function templateVariables(uriTemplate: string): TemplateVariable[] { if (part.kind !== "expression") continue; const required = !OMITTABLE_OPERATORS.has(part.operator); for (const name of part.names) { + const conforming = !part.varspecs.some( + (spec) => spec.name === name && spec.conforming === false, + ); const existing = byName.get(name); if (existing) { existing.required = existing.required || required; + existing.conforming = existing.conforming && conforming; } else { - byName.set(name, { name, operator: part.operator, required }); + byName.set(name, { + name, + operator: part.operator, + required, + conforming, + }); } } } @@ -398,6 +421,37 @@ const ALLOW_RESERVED = /[^A-Za-z0-9\-._~:/?#[\]@!$&'()*+,;=]/gu; * `%25`. The `u` flag makes the class match by code point, so an astral * character is handed to `encodeURIComponent` whole rather than as surrogates. */ +/** + * Percent-encodes a template's **literal** text (RFC 6570 §3.1). + * + * A literal may legally contain non-ASCII (`ucschar`), but expansion must emit + * it pct-encoded: the conformance case `café/{var}` expands to + * `caf%C3%A9/value`. Literals used to be copied through verbatim, so such a + * template went out with raw UTF-8 in the path — and since this module replaced + * the SDK's expander for both clients, nothing else was going to encode it. (The + * pinned SDK does not either; measured, it also returns `café/value`.) + * + * The rule is the same one {@link encodeAllowReserved} implements — keep + * unreserved, keep reserved (a literal's `/`, `?` and `#` are *structure*, not + * data), keep an existing pct-triplet, encode everything else — with one + * exception: a brace passes through. + * + * A brace is not a legal literal character, so it reaches this function only + * from a **malformed** template, where {@link parseUriTemplate} treats an + * unclosed `{` as trailing text. That case degrades to "show the template as + * the server wrote it", and encoding the brace to `%7B` would make the + * degradation unreadable — a string resembling neither the template nor + * anything the server could match. + */ +export function encodeLiteral(text: string): string { + return text + .split(/([{}])/g) + .map((chunk, index) => + index % 2 === 1 ? chunk : encodeAllowReserved(chunk), + ) + .join(""); +} + function encodeAllowReserved(value: string): string { return value .split(/(%[0-9A-Fa-f]{2})/g) @@ -487,8 +541,16 @@ function renderValue(value: string, spec: VarSpec, operator: string): string { return encodeValue(truncated, operator); } -/** Expands one expression per RFC 6570. Returns "" if no name has a value. */ -function expandExpression( +/** + * Expands one expression per RFC 6570. Returns "" if no name has a value. + * + * Exported for the web client's URI *preview*, which expands the expressions + * the user has filled while leaving the rest standing as written — it cannot + * go through {@link expandParts}, which expands all or nothing, and routing a + * half-rewritten template string back through the parser would mean smuggling + * a placeholder past {@link encodeLiteral}. + */ +export function expandTemplateExpression( part: TemplateExpression, values: Record, ): string { @@ -561,7 +623,9 @@ export function expandParts( ): string { return parts .map((part) => - part.kind === "literal" ? part.text : expandExpression(part, values), + part.kind === "literal" + ? encodeLiteral(part.text) + : expandTemplateExpression(part, values), ) .join(""); }