From 7b7f1bea82a737527a07b9d26e08d58ae9274699 Mon Sep 17 00:00:00 2001 From: Simon Johansson Date: Thu, 21 May 2026 15:38:59 +0200 Subject: [PATCH 1/3] Resolve `:` ambiguity in URL path placeholders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `:name` path placeholders were ambiguous when followed by a literal `:` in the same segment. The grammar had no terminator on the placeholder name, and the Rust resolver's anchor required `/?#$` after the name — so `/:id:literal` parsed as one big placeholder and never substituted. - URL grammar: placeholder name now excludes `:`, and a `/`-segment can contain Placeholder followed by literal PathSegment. `/:abc:def` now parses as Placeholder(:abc) + PathSegment(:def). - Rust resolver: add `:` to the trailing boundary set so `:id` in `:id:literal` substitutes correctly. - Editor rendering: filter Placeholder chip widgets to nodes actually at a `/`-segment start (the lexer over-emits Placeholder mid-segment on error recovery), and drop the `t.emphasis` style on Placeholder nodes — chip widgets fully replace the text, so the style was only ever visible on the spurious nodes we now filter. - Frontend regex: extract URL→placeholder extraction into a shared `extractPathPlaceholders` helper, tighten the regex to disallow `:` in placeholder names so partial typing and `:abc:def` are handled. Motivated by — but does not itself fix — OpenAPI imports of AIP-136 custom methods like `/tasks/{id}:increment-importance`. That symptom will be resolved separately when `openapi-to-postmanv2` picks up the upstream fix at postmanlabs/openapi-to-postman (PR pending) and the dep is bumped. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/HttpRequestPane.tsx | 5 ++-- .../components/WebsocketRequestPane.tsx | 5 ++-- .../core/Editor/twig/pathParameters.ts | 3 ++ .../components/core/Editor/url/highlight.ts | 5 +++- .../components/core/Editor/url/url.grammar | 7 +++-- .../components/core/Editor/url/url.terms.ts | 12 ++++---- .../components/core/Editor/url/url.test.ts | 29 +++++++++++++++++++ .../components/core/Editor/url/url.ts | 20 ++++++------- apps/yaak-client/lib/pathPlaceholders.test.ts | 28 ++++++++++++++++++ apps/yaak-client/lib/pathPlaceholders.ts | 14 +++++++++ crates/yaak-http/src/path_placeholders.rs | 17 ++++++++++- 11 files changed, 118 insertions(+), 27 deletions(-) create mode 100644 apps/yaak-client/components/core/Editor/url/url.test.ts create mode 100644 apps/yaak-client/lib/pathPlaceholders.test.ts create mode 100644 apps/yaak-client/lib/pathPlaceholders.ts diff --git a/apps/yaak-client/components/HttpRequestPane.tsx b/apps/yaak-client/components/HttpRequestPane.tsx index c5688a620..89d3bd7f3 100644 --- a/apps/yaak-client/components/HttpRequestPane.tsx +++ b/apps/yaak-client/components/HttpRequestPane.tsx @@ -19,6 +19,7 @@ import { useSendAnyHttpRequest } from "../hooks/useSendAnyHttpRequest"; import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; +import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { BODY_TYPE_BINARY, BODY_TYPE_FORM_MULTIPART, @@ -131,9 +132,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: ); const { urlParameterPairs, urlParametersKey } = useMemo(() => { - const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( - (m) => m[1] ?? "", - ); + const placeholderNames = extractPathPlaceholders(activeRequest.url); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const items: Pair[] = [...nonEmptyParameters]; for (const name of placeholderNames) { diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx index 83e6c1d60..af004c3dc 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -21,6 +21,7 @@ import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; +import { extractPathPlaceholders } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; import { resolvedModelName } from "../lib/resolvedModelName"; import { CountBadge } from "./core/CountBadge"; @@ -83,9 +84,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque ); const { urlParameterPairs, urlParametersKey } = useMemo(() => { - const placeholderNames = Array.from(activeRequest.url.matchAll(/\/(:[^/]+)/g)).map( - (m) => m[1] ?? "", - ); + const placeholderNames = extractPathPlaceholders(activeRequest.url); const nonEmptyParameters = activeRequest.urlParameters.filter((p) => p.name || p.value); const items: Pair[] = [...nonEmptyParameters]; for (const name of placeholderNames) { diff --git a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts index dc226adc4..c54d9bd85 100644 --- a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts +++ b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts @@ -60,6 +60,9 @@ function pathParameters( if (node.name !== "Placeholder") return; const globalFrom = innerTree.node.from + node.from; const globalTo = innerTree.node.from + node.to; + // A real path placeholder is preceded by `/`. This filters mid-segment + // Placeholder nodes (e.g. trailing `:literal` after `:id:literal`). + if (view.state.doc.sliceString(globalFrom - 1, globalFrom) !== "/") return; const rawText = view.state.doc.sliceString(globalFrom, globalTo); const onClick = () => onClickPathParameter(rawText); const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); diff --git a/apps/yaak-client/components/core/Editor/url/highlight.ts b/apps/yaak-client/components/core/Editor/url/highlight.ts index 69c8ac9be..5a49a9512 100644 --- a/apps/yaak-client/components/core/Editor/url/highlight.ts +++ b/apps/yaak-client/components/core/Editor/url/highlight.ts @@ -2,7 +2,10 @@ import { styleTags, tags as t } from "@lezer/highlight"; export const highlight = styleTags({ Protocol: t.comment, - Placeholder: t.emphasis, + // Placeholder nodes are rendered as chip widgets by `pathParameters.ts`, which + // replaces the underlying text — so a style on the text itself is invisible for + // valid placeholders and only ever appears on the spurious nodes the widget + // plugin filters out (e.g. the trailing `:literal` in `/:id:literal`). // PathSegment: t.tagName, // Host: t.variableName, // Path: t.bool, diff --git a/apps/yaak-client/components/core/Editor/url/url.grammar b/apps/yaak-client/components/core/Editor/url/url.grammar index 62c97dd85..84c026c36 100644 --- a/apps/yaak-client/components/core/Editor/url/url.grammar +++ b/apps/yaak-client/components/core/Editor/url/url.grammar @@ -1,6 +1,6 @@ @top url { Protocol? Host Path? Query? } -Path { ("/" (Placeholder | PathSegment))+ } +Path { ("/" (Placeholder PathSegment? | PathSegment))+ } Query { "?" queryPair ("&" queryPair)* } @@ -9,7 +9,10 @@ Query { "?" queryPair ("&" queryPair)* } Host { $[a-zA-Z0-9-_.:\[\]]+ } @precedence { Protocol, Host } - Placeholder { ":" ![/?#]+ } + // Placeholder name excludes `:` so a literal colon ends the placeholder. `/:abc:def` + // parses as Placeholder(:abc) + PathSegment(:def). `/abc:def` is all literal since + // the segment doesn't start with `:`. + Placeholder { ":" ![/?#:]+ } PathSegment { ![?#/]+ } @precedence { Placeholder, PathSegment } diff --git a/apps/yaak-client/components/core/Editor/url/url.terms.ts b/apps/yaak-client/components/core/Editor/url/url.terms.ts index 159035e3d..8a57dd228 100644 --- a/apps/yaak-client/components/core/Editor/url/url.terms.ts +++ b/apps/yaak-client/components/core/Editor/url/url.terms.ts @@ -1,9 +1,9 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const url = 1, +export const + url = 1, Protocol = 2, Host = 3, - Port = 4, - Path = 5, - Placeholder = 6, - PathSegment = 7, - Query = 8; + Path = 4, + Placeholder = 5, + PathSegment = 6, + Query = 7 diff --git a/apps/yaak-client/components/core/Editor/url/url.test.ts b/apps/yaak-client/components/core/Editor/url/url.test.ts new file mode 100644 index 000000000..c7e0ace9e --- /dev/null +++ b/apps/yaak-client/components/core/Editor/url/url.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "vite-plus/test"; +import { parser } from "./url"; + +function placeholderStarts(input: string): number[] { + const positions: number[] = []; + parser + .parse(input) + .cursor() + .iterate((node) => { + if (node.name === "Placeholder") positions.push(node.from); + }); + return positions; +} + +describe("URL grammar Placeholder", () => { + test("recognized after `/`", () => { + const url = "https://x.com/users/:id"; + const [pos] = placeholderStarts(url); + expect(url[pos - 1]).toBe("/"); + }); + + test("lexer over-emits a second Placeholder after a literal `:` (filter relies on this)", () => { + const url = "https://x.com/x/:id:def"; + const positions = placeholderStarts(url); + expect(positions.length).toBe(2); + expect(url[positions[0] - 1]).toBe("/"); + expect(url[positions[1] - 1]).not.toBe("/"); + }); +}); diff --git a/apps/yaak-client/components/core/Editor/url/url.ts b/apps/yaak-client/components/core/Editor/url/url.ts index ad421be67..1db5e833b 100644 --- a/apps/yaak-client/components/core/Editor/url/url.ts +++ b/apps/yaak-client/components/core/Editor/url/url.ts @@ -1,20 +1,18 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import { LRParser } from "@lezer/lr"; -import { highlight } from "./highlight"; +import {LRParser} from "@lezer/lr" +import {highlight} from "./highlight" export const parser = LRParser.deserialize({ version: 14, - states: - "!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c", - stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~", - goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[", + states: "#YOQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOzOQO,59OOOOO,59O,59OOOOO-E6b-E6bO!YOPO,58}OOOO1G.j1G.jO!bOSO'#CeO!gOPO1G.iOOOO,59P,59POOOO-E6c-E6c", + stateData: "!u~OQQORPO~OZRO[TO~OTWOUXO~OZROYSX[SX~O]ZO~OU[OYWaZWa[Wa~O^]OYVa~O]_O~O^]OYVi~OQRTUT~", + goto: "nYPPPPZPP^bhRVPTUPVQSPRYSQ^ZR`^", nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", maxTerm: 14, propSources: [highlight], skippedNodes: [0], repeatNodeCount: 2, - tokenData: - ".i~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+W!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)RQ)YUTQUQOs)Rt!P)R!Q!a)R!b;'S)R;'S;=`)l<%lO)RQ)oP;=`<%l)RR){cRPTQUQOs)Rt})R}!O)r!O!P)r!Q![)r![!])r!]!a)R!b!c)R!c!})r!}#O)r#O#P)R#P#Q)r#Q#R)R#R#S)r#S#T)R#T#o)r#o;'S)R;'S;=`)l<%lO)R~+]O[~V+fe]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],w!]!_!j!_!`&u!`!a!j!b!c!j!c!}+]!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+]#o;'S!j;'S;=`#R<%lO!jR-OdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.^!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.aP!P!Q.dP.iOQP", + tokenData: ".o~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+^!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)RQ)YWTQUQOs)Rt!P)R!Q![)R![!]!j!]!a)R!b;'S)R;'S;=`)r<%lO)RQ)uP;=`<%l)RR*RcRPTQUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)R~+cO[~V+le]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],}!]!_!j!_!`&u!`!a!j!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jR-UdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.d!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.gP!P!Q.jP.oOQP", tokenizers: [0, 1, 2], - topRules: { url: [0, 1] }, - tokenPrec: 63, -}); + topRules: {"url":[0,1]}, + tokenPrec: 77 +}) diff --git a/apps/yaak-client/lib/pathPlaceholders.test.ts b/apps/yaak-client/lib/pathPlaceholders.test.ts new file mode 100644 index 000000000..3c74b3151 --- /dev/null +++ b/apps/yaak-client/lib/pathPlaceholders.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "vite-plus/test"; +import { extractPathPlaceholders } from "./pathPlaceholders"; + +describe("extractPathPlaceholders", () => { + test("extracts a single placeholder", () => { + expect(extractPathPlaceholders("/users/:id")).toEqual([":id"]); + }); + + test("extracts multiple placeholders", () => { + expect(extractPathPlaceholders("/users/:id/posts/:postId")).toEqual([":id", ":postId"]); + }); + + test("stops at a literal `:` in the same segment", () => { + expect(extractPathPlaceholders("/tasks/:id:cancel")).toEqual([":id"]); + }); + + test("does not match `:foo` mid-segment", () => { + expect(extractPathPlaceholders("/users/abc:def")).toEqual([]); + }); + + test("does not match `:` in a host port", () => { + expect(extractPathPlaceholders("https://example.com:8080/users/:id")).toEqual([":id"]); + }); + + test("returns empty for a URL with no placeholders", () => { + expect(extractPathPlaceholders("https://example.com/foo/bar?q=1#hash")).toEqual([]); + }); +}); diff --git a/apps/yaak-client/lib/pathPlaceholders.ts b/apps/yaak-client/lib/pathPlaceholders.ts new file mode 100644 index 000000000..f6dab4de2 --- /dev/null +++ b/apps/yaak-client/lib/pathPlaceholders.ts @@ -0,0 +1,14 @@ +/** + * Extract `:name`-style path placeholders from a URL string. + * + * A placeholder is `:` followed by one-or-more characters that are not `/`, `?`, + * `#`, or `:`. The `:` boundary means a placeholder ends where a literal colon + * starts in the same segment, e.g. `/tasks/:id:increment-importance` yields one + * placeholder `:id` and `:increment-importance` is literal text. + * + * Only `:` that sits at the start of a `/`-delimited segment counts — `/abc:def` + * has no placeholders. Returned names include the leading colon. + */ +export function extractPathPlaceholders(url: string): string[] { + return Array.from(url.matchAll(/\/(:[^/?#:]+)/g)).map((m) => m[1] ?? ""); +} diff --git a/crates/yaak-http/src/path_placeholders.rs b/crates/yaak-http/src/path_placeholders.rs index 9a700e82c..25aee7896 100644 --- a/crates/yaak-http/src/path_placeholders.rs +++ b/crates/yaak-http/src/path_placeholders.rs @@ -34,7 +34,10 @@ fn replace_path_placeholder(p: &HttpUrlParameter, url: &str) -> String { return url.to_string(); } - let re = regex::Regex::new(format!("(/){}([/?#]|$)", p.name).as_str()).unwrap(); + // A path placeholder is terminated by `/`, `?`, `#`, end-of-string, or a literal `:`. + // The `:` boundary is what lets `/:id:increment-importance` substitute the `:id` + // placeholder while leaving `:increment-importance` as literal text. + let re = regex::Regex::new(format!("(/){}([/?#:]|$)", p.name).as_str()).unwrap(); let result = re .replace_all(url, |cap: ®ex::Captures| { format!( @@ -83,6 +86,18 @@ mod placeholder_tests { ); } + #[test] + fn placeholder_followed_by_literal_colon() { + // AIP-136-style custom method: `:id` is the placeholder, `:increment-importance` + // is literal text in the same path segment. + let p = + HttpUrlParameter { name: ":id".into(), value: "42".into(), enabled: true, id: None }; + assert_eq!( + replace_path_placeholder(&p, "https://example.com/tasks/:id:increment-importance"), + "https://example.com/tasks/42:increment-importance", + ); + } + #[test] fn placeholder_missing() { let p = HttpUrlParameter { From 52ee7b6fd44f4069fbac2d1912397a2f8a1ba967 Mon Sep 17 00:00:00 2001 From: Simon Johansson Date: Fri, 22 May 2026 09:33:51 +0200 Subject: [PATCH 2/3] Render path-only URLs with leading `:` placeholders as chips `/:foo/:bar` previously left `:foo` un-rendered as a chip. Two underlying issues, both fixed: - URL grammar required `Host`. For a URL starting with `/`, the parser error-recovered past the leading slash and consumed the first segment as Host (since Host's char class includes `:` for `host:port`), eating an initial `:name` placeholder. Make Host optional so path-only URLs go straight to the Path branch. - `pathParameters.ts` looked for the `url` overlay node to find Placeholders. With Host optional, parses without a Host produce `Path` as the topmost overlay node (since parseMixed drops the inner top wrapper when there's no surrounding error). Accept either `url` or `Path` as the entry point. Regression test added in `url.test.ts` covering the `/:chip1/:chip2` shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../core/Editor/twig/pathParameters.ts | 6 ++++-- .../components/core/Editor/url/url.grammar | 6 +++++- .../components/core/Editor/url/url.test.ts | 10 ++++++++++ .../components/core/Editor/url/url.ts | 20 ++++++++++--------- 4 files changed, 30 insertions(+), 12 deletions(-) diff --git a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts index c54d9bd85..ee41751a4 100644 --- a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts +++ b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts @@ -51,10 +51,12 @@ function pathParameters( to, enter(node) { if (node.name === "Text") { - // Find the `url` node and then jump into it to find the placeholders + // Find the URL overlay root. With `Host?` optional, a path-only URL like + // `/:foo/:bar` produces `Path` as the topmost overlay node instead of `url`, + // so accept either. for (let i = node.from; i < node.to; i++) { const innerTree = syntaxTree(view.state).resolveInner(i); - if (innerTree.node.name === "url") { + if (innerTree.node.name === "url" || innerTree.node.name === "Path") { innerTree.toTree().iterate({ enter(node) { if (node.name !== "Placeholder") return; diff --git a/apps/yaak-client/components/core/Editor/url/url.grammar b/apps/yaak-client/components/core/Editor/url/url.grammar index 84c026c36..8b28fd06b 100644 --- a/apps/yaak-client/components/core/Editor/url/url.grammar +++ b/apps/yaak-client/components/core/Editor/url/url.grammar @@ -1,4 +1,8 @@ -@top url { Protocol? Host Path? Query? } +// Host is optional so URLs starting with `/` go straight to Path. Without this, +// the parser error-recovers past the leading `/` and consumes the first segment as +// Host (since Host's char class includes `:` for `host:port`), eating an initial +// `:name` placeholder like `/:foo/:bar`. +@top url { Protocol? Host? Path? Query? } Path { ("/" (Placeholder PathSegment? | PathSegment))+ } diff --git a/apps/yaak-client/components/core/Editor/url/url.test.ts b/apps/yaak-client/components/core/Editor/url/url.test.ts index c7e0ace9e..76d3f98c4 100644 --- a/apps/yaak-client/components/core/Editor/url/url.test.ts +++ b/apps/yaak-client/components/core/Editor/url/url.test.ts @@ -26,4 +26,14 @@ describe("URL grammar Placeholder", () => { expect(url[positions[0] - 1]).toBe("/"); expect(url[positions[1] - 1]).not.toBe("/"); }); + + test("first segment of a path-only URL is a Placeholder, not eaten as Host", () => { + // Regression: without `Host?`, the first `:chip1` would be tokenized as Host + // (Host's char class includes `:` for `host:port`), leaving only `:chip2` as + // a Placeholder. + const url = "/:chip1/:chip2"; + const positions = placeholderStarts(url); + expect(positions.length).toBe(2); + expect(url.slice(positions[0])).toMatch(/^:chip1/); + }); }); diff --git a/apps/yaak-client/components/core/Editor/url/url.ts b/apps/yaak-client/components/core/Editor/url/url.ts index 1db5e833b..911b6646f 100644 --- a/apps/yaak-client/components/core/Editor/url/url.ts +++ b/apps/yaak-client/components/core/Editor/url/url.ts @@ -1,18 +1,20 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import {LRParser} from "@lezer/lr" -import {highlight} from "./highlight" +import { LRParser } from "@lezer/lr"; +import { highlight } from "./highlight"; export const parser = LRParser.deserialize({ version: 14, - states: "#YOQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOzOQO,59OOOOO,59O,59OOOOO-E6b-E6bO!YOPO,58}OOOO1G.j1G.jO!bOSO'#CeO!gOPO1G.iOOOO,59P,59POOOO-E6c-E6c", - stateData: "!u~OQQORPO~OZRO[TO~OTWOUXO~OZROYSX[SX~O]ZO~OU[OYWaZWa[Wa~O^]OYVa~O]_O~O^]OYVi~OQRTUT~", - goto: "nYPPPPZPP^bhRVPTUPVQSPRYSQ^ZR`^", + states: + "#YQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO,59OOOOO,59O,59OOOOO-E6b-E6bO!WOPO,58}OOOO1G.j1G.jO!`OSO'#CeO!eOPO1G.iOOOO,59P,59POOOO-E6c-E6c", + stateData: "!s~OQVORUOZPO[RO~OTWOUXO~OZPOYSX[SX~O]ZO~OU[OYWaZWa[Wa~O^]OYVa~O]_O~O^]OYVi~OQRTUT~", + goto: "tYPPPPZPP`fnVTOUVXSOTUVUQOUVRYQQ^ZR`^", nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", maxTerm: 14, propSources: [highlight], skippedNodes: [0], repeatNodeCount: 2, - tokenData: ".o~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+^!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)RQ)YWTQUQOs)Rt!P)R!Q![)R![!]!j!]!a)R!b;'S)R;'S;=`)r<%lO)RQ)uP;=`<%l)RR*RcRPTQUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)R~+cO[~V+le]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],}!]!_!j!_!`&u!`!a!j!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jR-UdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.d!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.gP!P!Q.jP.oOQP", + tokenData: + ".o~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+^!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)RQ)YWTQUQOs)Rt!P)R!Q![)R![!]!j!]!a)R!b;'S)R;'S;=`)r<%lO)RQ)uP;=`<%l)RR*RcRPTQUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)R~+cO[~V+le]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],}!]!_!j!_!`&u!`!a!j!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jR-UdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.d!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.gP!P!Q.jP.oOQP", tokenizers: [0, 1, 2], - topRules: {"url":[0,1]}, - tokenPrec: 77 -}) + topRules: { url: [0, 1] }, + tokenPrec: 75, +}); From dd8e3f8479dd0a34d9b8415a5c657c327959c8bd Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Wed, 1 Jul 2026 13:18:33 -0700 Subject: [PATCH 3/3] Clean up URL path placeholder parsing --- .../core/Editor/twig/pathParameters.ts | 31 ++++------ .../components/core/Editor/url/highlight.ts | 5 +- .../components/core/Editor/url/url.grammar | 12 ++-- .../components/core/Editor/url/url.terms.ts | 4 +- .../components/core/Editor/url/url.test.ts | 57 ++++++++++++------- .../components/core/Editor/url/url.ts | 26 ++++----- 6 files changed, 67 insertions(+), 68 deletions(-) diff --git a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts index ee41751a4..2f76dbd18 100644 --- a/apps/yaak-client/components/core/Editor/twig/pathParameters.ts +++ b/apps/yaak-client/components/core/Editor/twig/pathParameters.ts @@ -51,26 +51,19 @@ function pathParameters( to, enter(node) { if (node.name === "Text") { - // Find the URL overlay root. With `Host?` optional, a path-only URL like - // `/:foo/:bar` produces `Path` as the topmost overlay node instead of `url`, - // so accept either. + // Find the `url` node and then jump into it to find the placeholders for (let i = node.from; i < node.to; i++) { - const innerTree = syntaxTree(view.state).resolveInner(i); - if (innerTree.node.name === "url" || innerTree.node.name === "Path") { - innerTree.toTree().iterate({ - enter(node) { - if (node.name !== "Placeholder") return; - const globalFrom = innerTree.node.from + node.from; - const globalTo = innerTree.node.from + node.to; - // A real path placeholder is preceded by `/`. This filters mid-segment - // Placeholder nodes (e.g. trailing `:literal` after `:id:literal`). - if (view.state.doc.sliceString(globalFrom - 1, globalFrom) !== "/") return; - const rawText = view.state.doc.sliceString(globalFrom, globalTo); - const onClick = () => onClickPathParameter(rawText); - const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); - const deco = Decoration.replace({ widget, inclusive: false }); - widgets.push(deco.range(globalFrom, globalTo)); - }, + const innerTree = tree.resolveInner(i); + if (innerTree.node.name === "url") { + innerTree.node.cursor().iterate((node) => { + if (node.name !== "Placeholder") return; + const globalFrom = node.from; + const globalTo = node.to; + const rawText = view.state.doc.sliceString(globalFrom, globalTo); + const onClick = () => onClickPathParameter(rawText); + const widget = new PathPlaceholderWidget(rawText, globalFrom, onClick); + const deco = Decoration.replace({ widget, inclusive: false }); + widgets.push(deco.range(globalFrom, globalTo)); }); break; } diff --git a/apps/yaak-client/components/core/Editor/url/highlight.ts b/apps/yaak-client/components/core/Editor/url/highlight.ts index 5a49a9512..69c8ac9be 100644 --- a/apps/yaak-client/components/core/Editor/url/highlight.ts +++ b/apps/yaak-client/components/core/Editor/url/highlight.ts @@ -2,10 +2,7 @@ import { styleTags, tags as t } from "@lezer/highlight"; export const highlight = styleTags({ Protocol: t.comment, - // Placeholder nodes are rendered as chip widgets by `pathParameters.ts`, which - // replaces the underlying text — so a style on the text itself is invisible for - // valid placeholders and only ever appears on the spurious nodes the widget - // plugin filters out (e.g. the trailing `:literal` in `/:id:literal`). + Placeholder: t.emphasis, // PathSegment: t.tagName, // Host: t.variableName, // Path: t.bool, diff --git a/apps/yaak-client/components/core/Editor/url/url.grammar b/apps/yaak-client/components/core/Editor/url/url.grammar index 8b28fd06b..5e9c80d78 100644 --- a/apps/yaak-client/components/core/Editor/url/url.grammar +++ b/apps/yaak-client/components/core/Editor/url/url.grammar @@ -4,7 +4,10 @@ // `:name` placeholder like `/:foo/:bar`. @top url { Protocol? Host? Path? Query? } -Path { ("/" (Placeholder PathSegment? | PathSegment))+ } +Path { ("/" PathSegment)+ } + +Placeholder { ":" pathChars } +PathSegment { Placeholder (":" pathChars)* | pathChars (":" pathChars)* } Query { "?" queryPair ("&" queryPair)* } @@ -13,12 +16,7 @@ Query { "?" queryPair ("&" queryPair)* } Host { $[a-zA-Z0-9-_.:\[\]]+ } @precedence { Protocol, Host } - // Placeholder name excludes `:` so a literal colon ends the placeholder. `/:abc:def` - // parses as Placeholder(:abc) + PathSegment(:def). `/abc:def` is all literal since - // the segment doesn't start with `:`. - Placeholder { ":" ![/?#:]+ } - PathSegment { ![?#/]+ } - @precedence { Placeholder, PathSegment } + pathChars { ![/?#:]+ } queryPair { ($[a-zA-Z0-9]+ ("=" $[a-zA-Z0-9]*)?) } } diff --git a/apps/yaak-client/components/core/Editor/url/url.terms.ts b/apps/yaak-client/components/core/Editor/url/url.terms.ts index 8a57dd228..3af756955 100644 --- a/apps/yaak-client/components/core/Editor/url/url.terms.ts +++ b/apps/yaak-client/components/core/Editor/url/url.terms.ts @@ -4,6 +4,6 @@ export const Protocol = 2, Host = 3, Path = 4, - Placeholder = 5, - PathSegment = 6, + PathSegment = 5, + Placeholder = 6, Query = 7 diff --git a/apps/yaak-client/components/core/Editor/url/url.test.ts b/apps/yaak-client/components/core/Editor/url/url.test.ts index 76d3f98c4..8585d9a31 100644 --- a/apps/yaak-client/components/core/Editor/url/url.test.ts +++ b/apps/yaak-client/components/core/Editor/url/url.test.ts @@ -1,39 +1,52 @@ import { describe, expect, test } from "vite-plus/test"; import { parser } from "./url"; -function placeholderStarts(input: string): number[] { - const positions: number[] = []; +function expectValidParse(input: string) { + expect(parser.parse(input).toString()).not.toContain("⚠"); +} + +function placeholderValues(input: string): string[] { + const values: string[] = []; parser .parse(input) .cursor() .iterate((node) => { - if (node.name === "Placeholder") positions.push(node.from); + if (node.name === "Placeholder") values.push(input.slice(node.from, node.to)); }); - return positions; + return values; } describe("URL grammar Placeholder", () => { - test("recognized after `/`", () => { - const url = "https://x.com/users/:id"; - const [pos] = placeholderStarts(url); - expect(url[pos - 1]).toBe("/"); + test("recognizes path placeholders", () => { + expectValidParse("https://x.com/users/:id"); + expect(placeholderValues("https://x.com/users/:id")).toEqual([":id"]); + }); + + test("treats a colon suffix as literal path text", () => { + expectValidParse("https://yaak.app/x/echo/:foo:bar/baz"); + expect(placeholderValues("https://yaak.app/x/echo/:foo:bar/baz")).toEqual([":foo"]); + }); + + test("treats repeated colon suffixes as literal path text", () => { + expectValidParse("https://yaak.app/x/echo/:foo:bar:baz"); + expect(placeholderValues("https://yaak.app/x/echo/:foo:bar:baz")).toEqual([":foo"]); + }); + + test("does not recognize a colon in the middle of a plain path segment", () => { + expectValidParse("https://yaak.app/x/echo/foo:bar/baz"); + expect(placeholderValues("https://yaak.app/x/echo/foo:bar/baz")).toEqual([]); }); - test("lexer over-emits a second Placeholder after a literal `:` (filter relies on this)", () => { - const url = "https://x.com/x/:id:def"; - const positions = placeholderStarts(url); - expect(positions.length).toBe(2); - expect(url[positions[0] - 1]).toBe("/"); - expect(url[positions[1] - 1]).not.toBe("/"); + test("does not recognize query parameters as path placeholders", () => { + expect(placeholderValues("https://yaak.app/x/echo/:foo?bar=ss&:bar=baz")).toEqual([":foo"]); }); - test("first segment of a path-only URL is a Placeholder, not eaten as Host", () => { - // Regression: without `Host?`, the first `:chip1` would be tokenized as Host - // (Host's char class includes `:` for `host:port`), leaving only `:chip2` as - // a Placeholder. - const url = "/:chip1/:chip2"; - const positions = placeholderStarts(url); - expect(positions.length).toBe(2); - expect(url.slice(positions[0])).toMatch(/^:chip1/); + test("recognizes placeholders in a path fragment after a templated base URL", () => { + // Mixed Twig parsing can feed the URL parser only the text after a template tag, + // as in `${[ URL ]}/x/:foo/:hello`. + expect(placeholderValues("/x/hi:echo/:foo/:hello?bar=ss&:bar=baz")).toEqual([ + ":foo", + ":hello", + ]); }); }); diff --git a/apps/yaak-client/components/core/Editor/url/url.ts b/apps/yaak-client/components/core/Editor/url/url.ts index 911b6646f..359d4de93 100644 --- a/apps/yaak-client/components/core/Editor/url/url.ts +++ b/apps/yaak-client/components/core/Editor/url/url.ts @@ -1,20 +1,18 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import { LRParser } from "@lezer/lr"; -import { highlight } from "./highlight"; +import {LRParser} from "@lezer/lr" +import {highlight} from "./highlight" export const parser = LRParser.deserialize({ version: 14, - states: - "#YQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO,59OOOOO,59O,59OOOOO-E6b-E6bO!WOPO,58}OOOO1G.j1G.jO!`OSO'#CeO!eOPO1G.iOOOO,59P,59POOOO-E6c-E6c", - stateData: "!s~OQVORUOZPO[RO~OTWOUXO~OZPOYSX[SX~O]ZO~OU[OYWaZWa[Wa~O^]OYVa~O]_O~O^]OYVi~OQRTUT~", - goto: "tYPPPPZPP`fnVTOUVXSOTUVUQOUVRYQQ^ZR`^", - nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query", - maxTerm: 14, + states: "#xQQOPOOO`OQO'#CdOhOPO'#C`OsOSO'#CcQOOOOOQZOPOOQWOPOOQTOPOOOxOQO'#CbO}OQO'#CaOOOO,59O,59OOOOO-E6b-E6bO!]OPO,58}OOOO,58|,58|O!eOQO'#CeO!jOQO,58{O!xOSO'#CfO!}OPO1G.iOOOO,59P,59POOOO-E6c-E6cOOOO,59Q,59QOOOO-E6d-E6d", + stateData: "#Y~OQVORUO[PO_RO~O]WO^XO~O[POZSX_SX~O`[O~O^]O~O]^OZTX[TX_TX~Oa`OZVa~O^bO~O]^OZTa[Ta_Ta~O`dO~Oa`OZVi~OQR~", + goto: "!RZPPPP[adgmu{VTOUVRYPRXPXSOTUVUQOUVRZQQ_XRc_Qa[Rea", + nodeNames: "⚠ url Protocol Host Path PathSegment Placeholder Query", + maxTerm: 17, propSources: [highlight], skippedNodes: [0], - repeatNodeCount: 2, - tokenData: - ".o~RgOs!jtv!jvw#Xw}!j}!O#r!O!P#r!P!Q%U!Q![%Z![!]'o!]!a!j!a!b+^!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jQ!oUUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jQ#UP;=`<%l!jR#`U^PUQOs!jt!P!j!Q!a!j!b;'S!j;'S;=`#R<%lO!jR#ycRPUQOs!jt}!j}!O#r!O!P#r!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!j~%ZOZ~V%de]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!]#r!]!_!j!_!`&u!`!a!j!b!c!j!c!}%Z!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o%Z#o;'S!j;'S;=`#R<%lO!jU&|Z]SUQOs!jt!P!j!Q![&u![!a!j!b!c!j!c!}&u!}#T!j#T#o&u#o;'S!j;'S;=`#R<%lO!jR'vcRPUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)RQ)YWTQUQOs)Rt!P)R!Q![)R![!]!j!]!a)R!b;'S)R;'S;=`)r<%lO)RQ)uP;=`<%l)RR*RcRPTQUQOs)Rt})R}!O)x!O!P)x!Q![)x![!]#r!]!a)R!b!c)R!c!})x!}#O)x#O#P)R#P#Q)x#Q#R)R#R#S)x#S#T)R#T#o)x#o;'S)R;'S;=`)r<%lO)R~+cO[~V+le]SRPUQOs!jt}!j}!O#r!O!P#r!Q![%Z![!],}!]!_!j!_!`&u!`!a!j!b!c!j!c!}+c!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o+c#o;'S!j;'S;=`#R<%lO!jR-UdRPUQOs!jt}!j}!O#r!O!P#r!P!Q.d!Q![#r![!]#r!]!a!j!b!c!j!c!}#r!}#O#r#O#P!j#P#Q#r#Q#R!j#R#S#r#S#T!j#T#o#r#o;'S!j;'S;=`#R<%lO!jP.gP!P!Q.jP.oOQP", + repeatNodeCount: 3, + tokenData: "+z~RgOs!jtv!jvw#[w}!j}!O#x!O!P#x!P!Q%|!Q![&R![!](g!]!a!j!a!b)Z!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jQ!oV^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jQ#XP;=`<%l!jR#cVaP^QOs!jt!P!j!Q![!j!]!a!j!b;'S!j;'S;=`#U<%lO!jR$Pc^QRPOs!jt}!j}!O#x!O!P#x!Q![#x![!]%[!]!a!j!b!c!j!c!}#x!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o#x#o;'S!j;'S;=`#U<%lO!jP%aXRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~&RO[~V&[e^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]%[!]!_!j!_!`'m!`!a!j!b!c!j!c!}&R!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o&R#o;'S!j;'S;=`#U<%lO!jU'tZ^Q`SOs!jt!P!j!Q!['m!]!a!j!b!c!j!c!}'m!}#T!j#T#o'm#o;'S!j;'S;=`#U<%lO!jR(nX]QRP}!O%[!O!P%[!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[~)`O_~V)ie^Q`SRPOs!jt}!j}!O#x!O!P#x!Q![&R![!]*z!]!_!j!_!`'m!`!a!j!b!c!j!c!})`!}#O#x#O#P!j#P#Q#x#Q#R!j#R#S#x#S#T!j#T#o)`#o;'S!j;'S;=`#U<%lO!jP+PYRP}!O%[!O!P%[!P!Q+o!Q![%[![!]%[!c!}%[!}#O%[#P#Q%[#R#S%[#T#o%[P+rP!P!Q+uP+zOQP", tokenizers: [0, 1, 2], - topRules: { url: [0, 1] }, - tokenPrec: 75, -}); + topRules: {"url":[0,1]}, + tokenPrec: 99 +})