@fedify/uri-template 2.0.0-pr.475.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +20 -0
- package/README.md +78 -0
- package/dist/_virtual/rolldown_runtime.cjs +30 -0
- package/dist/ast.d.cts +39 -0
- package/dist/ast.d.ts +39 -0
- package/dist/compile.cjs +30 -0
- package/dist/compile.d.cts +80 -0
- package/dist/compile.d.ts +80 -0
- package/dist/compile.js +30 -0
- package/dist/error.cjs +29 -0
- package/dist/error.js +28 -0
- package/dist/expand.cjs +94 -0
- package/dist/expand.d.cts +72 -0
- package/dist/expand.d.ts +72 -0
- package/dist/expand.js +94 -0
- package/dist/index.cjs +9 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/match.cjs +180 -0
- package/dist/match.d.cts +92 -0
- package/dist/match.d.ts +92 -0
- package/dist/match.js +180 -0
- package/dist/match.spec.cjs +109 -0
- package/dist/match.spec.d.cts +1 -0
- package/dist/match.spec.d.ts +1 -0
- package/dist/match.spec.js +108 -0
- package/dist/parser.cjs +102 -0
- package/dist/parser.d.cts +35 -0
- package/dist/parser.d.ts +35 -0
- package/dist/parser.js +102 -0
- package/dist/rfc6570.spec.cjs +419 -0
- package/dist/rfc6570.spec.d.cts +1 -0
- package/dist/rfc6570.spec.d.ts +1 -0
- package/dist/rfc6570.spec.js +418 -0
- package/dist/spec.cjs +112 -0
- package/dist/spec.js +108 -0
- package/package.json +60 -0
package/dist/match.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { OP, looksLikePctTriplet, strictPercentDecode } from "./spec.js";
|
|
2
|
+
|
|
3
|
+
//#region src/match.ts
|
|
4
|
+
function decodeAccordingToPolicy(s, policy) {
|
|
5
|
+
if (policy === "opaque") return s;
|
|
6
|
+
if (policy === "cooked") return strictPercentDecode(s);
|
|
7
|
+
return {
|
|
8
|
+
raw: s,
|
|
9
|
+
decoded: strictPercentDecode(s)
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* consume a percent triplet or a single char; used to advance safely
|
|
14
|
+
*
|
|
15
|
+
* Percent triplet handling policies:
|
|
16
|
+
* - When advancing, treat "%XX" as a single atom to avoid slicing inside a byte.
|
|
17
|
+
* - In strict mode, a bare '%' or bad hex after '%' fails the match.
|
|
18
|
+
*/
|
|
19
|
+
function advanceOne(url, i) {
|
|
20
|
+
if (i < url.length && url[i] === "%" && looksLikePctTriplet(url, i)) return i + 3;
|
|
21
|
+
return i + 1;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Match a URL string against a compiled template and extract variables.
|
|
25
|
+
*
|
|
26
|
+
* @param ast - The parsed template AST to match against
|
|
27
|
+
* @param url - The URL string to match
|
|
28
|
+
* @param opts - Optional matching options
|
|
29
|
+
* @returns An object with extracted variables if matched, null otherwise
|
|
30
|
+
*
|
|
31
|
+
* Encoding policies:
|
|
32
|
+
* - "opaque" -> return raw percent-encoded slices (byte-for-byte round-trip)
|
|
33
|
+
* - "cooked" -> decode %XX exactly once
|
|
34
|
+
* - "lossless"-> { raw, decoded } pair
|
|
35
|
+
*
|
|
36
|
+
* Strictness:
|
|
37
|
+
* - strict=true rejects bad percent triplets (e.g. "%GZ"), preventing
|
|
38
|
+
* ambiguous or lossy normalization early.
|
|
39
|
+
*
|
|
40
|
+
* @example
|
|
41
|
+
* ```typescript
|
|
42
|
+
* import { parse } from "./parser.ts";
|
|
43
|
+
* import { match } from "./match.ts";
|
|
44
|
+
*
|
|
45
|
+
* const ast = parse("/repos{/owner,repo}{?q,lang}");
|
|
46
|
+
* const result = match(ast, "/repos/alice/hello%2Fworld?q=a%20b&lang=en", { encoding: "opaque" });
|
|
47
|
+
* // Returns: { vars: { owner: "alice", repo: "hello%2Fworld", q: "a%20b", lang: "en" } }
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* ```typescript
|
|
52
|
+
* import { parse } from "./parser.ts";
|
|
53
|
+
* import { match } from "./match.ts";
|
|
54
|
+
*
|
|
55
|
+
* const ast = parse("/files{/path}");
|
|
56
|
+
* const result = match(ast, "/files/a%2Fb", { encoding: "cooked" });
|
|
57
|
+
* // Returns: { vars: { path: "a/b" } }
|
|
58
|
+
* ```
|
|
59
|
+
*/
|
|
60
|
+
function match(ast, url, opts) {
|
|
61
|
+
const policy = opts?.encoding ?? "opaque";
|
|
62
|
+
const strict = opts?.strict ?? true;
|
|
63
|
+
let i = 0;
|
|
64
|
+
const varsOut = {};
|
|
65
|
+
const readLiteral = (lit) => {
|
|
66
|
+
if (url.slice(i, i + lit.length) !== lit) return false;
|
|
67
|
+
i += lit.length;
|
|
68
|
+
return true;
|
|
69
|
+
};
|
|
70
|
+
const nextIs = (s) => url.slice(i, i + s.length) === s;
|
|
71
|
+
for (const node of ast.nodes) {
|
|
72
|
+
if (node.kind === "literal") {
|
|
73
|
+
if (!readLiteral(node.value)) return null;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const spec = OP[node.op];
|
|
77
|
+
if (spec.first) {
|
|
78
|
+
if (!readLiteral(spec.first)) return null;
|
|
79
|
+
}
|
|
80
|
+
const takeUntil = (stopPred) => {
|
|
81
|
+
const start = i;
|
|
82
|
+
while (i < url.length && !stopPred(i)) {
|
|
83
|
+
if (strict && url[i] === "%" && !looksLikePctTriplet(url, i)) return "";
|
|
84
|
+
i = advanceOne(url, i);
|
|
85
|
+
}
|
|
86
|
+
return url.slice(start, i);
|
|
87
|
+
};
|
|
88
|
+
const emitScalar = (v, raw) => {
|
|
89
|
+
varsOut[v.name] = decodeAccordingToPolicy(raw, policy);
|
|
90
|
+
};
|
|
91
|
+
const splitItems = (raw) => {
|
|
92
|
+
if (raw.length === 0) return [""];
|
|
93
|
+
const sep = spec.itemSep;
|
|
94
|
+
const out = [];
|
|
95
|
+
let start = 0;
|
|
96
|
+
for (let j = 0; j <= raw.length;) {
|
|
97
|
+
if (j === raw.length || raw.slice(j, j + sep.length) === sep) {
|
|
98
|
+
out.push(raw.slice(start, j));
|
|
99
|
+
j += sep.length;
|
|
100
|
+
start = j;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
j = advanceOne(raw, j);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
};
|
|
107
|
+
const captureOneVar = (v, last, nextLiteral) => {
|
|
108
|
+
const stopPred = (j) => {
|
|
109
|
+
if (nextLiteral && url.slice(j, j + nextLiteral.length) === nextLiteral) return true;
|
|
110
|
+
if (!last && spec.itemSep && url.slice(j, j + spec.itemSep.length) === spec.itemSep) return true;
|
|
111
|
+
return false;
|
|
112
|
+
};
|
|
113
|
+
const raw = takeUntil(stopPred);
|
|
114
|
+
if (raw === "" && strict && i < url.length && url[i] !== spec.itemSep && nextLiteral === null) return false;
|
|
115
|
+
if (v.explode) {
|
|
116
|
+
const parts = splitItems(raw);
|
|
117
|
+
if (spec.named) varsOut[v.name] = parseExplodeNamedParts(v, parts, spec, policy);
|
|
118
|
+
else varsOut[v.name] = parts.map((p) => decodeAccordingToPolicy(p, policy));
|
|
119
|
+
} else if (spec.named) {
|
|
120
|
+
const [ok, val] = captureNamedNonExplode(v, raw, spec, policy);
|
|
121
|
+
if (!ok) return false;
|
|
122
|
+
varsOut[v.name] = val;
|
|
123
|
+
} else emitScalar(v, raw);
|
|
124
|
+
if (!last && spec.itemSep && nextIs(spec.itemSep)) i += spec.itemSep.length;
|
|
125
|
+
return true;
|
|
126
|
+
};
|
|
127
|
+
for (let idx = 0; idx < node.vars.length; idx++) {
|
|
128
|
+
const v = node.vars[idx];
|
|
129
|
+
const last = idx === node.vars.length - 1;
|
|
130
|
+
let nextLiteral = null;
|
|
131
|
+
const nodeIndex = ast.nodes.indexOf(node);
|
|
132
|
+
for (let k = nodeIndex + 1; k < ast.nodes.length; k++) {
|
|
133
|
+
const n = ast.nodes[k];
|
|
134
|
+
if (n.kind === "literal" && n.value.length > 0) {
|
|
135
|
+
nextLiteral = n.value;
|
|
136
|
+
break;
|
|
137
|
+
}
|
|
138
|
+
if (n.kind === "expression" && OP[n.op].first) {
|
|
139
|
+
nextLiteral = OP[n.op].first;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (!captureOneVar(v, last, nextLiteral)) return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { vars: varsOut };
|
|
147
|
+
}
|
|
148
|
+
function captureNamedNonExplode(v, raw, spec, policy) {
|
|
149
|
+
const eq = raw.indexOf(spec.kvSep);
|
|
150
|
+
if (eq === -1) {
|
|
151
|
+
if (spec.first === ";") {
|
|
152
|
+
if (raw === v.name) return [true, decodeAccordingToPolicy("", policy)];
|
|
153
|
+
}
|
|
154
|
+
return [false, null];
|
|
155
|
+
}
|
|
156
|
+
const lhs = raw.slice(0, eq), rhs = raw.slice(eq + spec.kvSep.length);
|
|
157
|
+
if (lhs !== v.name) return [false, null];
|
|
158
|
+
return [true, decodeAccordingToPolicy(rhs, policy)];
|
|
159
|
+
}
|
|
160
|
+
function parseExplodeNamedParts(v, parts, spec, policy) {
|
|
161
|
+
const allVarName = parts.every((p) => p.startsWith(v.name + spec.kvSep));
|
|
162
|
+
if (allVarName) return parts.map((p) => {
|
|
163
|
+
const rhs = p.slice((v.name + spec.kvSep).length);
|
|
164
|
+
return decodeAccordingToPolicy(rhs, policy);
|
|
165
|
+
});
|
|
166
|
+
const obj = {};
|
|
167
|
+
for (const p of parts) {
|
|
168
|
+
const i = p.indexOf(spec.kvSep);
|
|
169
|
+
if (i < 0) {
|
|
170
|
+
obj[decodeAccordingToPolicy(p, policy)] = "";
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
const k = p.slice(0, i), val = p.slice(i + spec.kvSep.length);
|
|
174
|
+
obj[decodeAccordingToPolicy(k, policy)] = decodeAccordingToPolicy(val, policy);
|
|
175
|
+
}
|
|
176
|
+
return obj;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
//#endregion
|
|
180
|
+
export { match };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const require_rolldown_runtime = require('./_virtual/rolldown_runtime.cjs');
|
|
2
|
+
const require_compile = require('./compile.cjs');
|
|
3
|
+
const node_assert_strict = require_rolldown_runtime.__toESM(require("node:assert/strict"));
|
|
4
|
+
const node_test = require_rolldown_runtime.__toESM(require("node:test"));
|
|
5
|
+
|
|
6
|
+
//#region src/match.spec.ts
|
|
7
|
+
(0, node_test.describe)("encoding modes", () => {
|
|
8
|
+
(0, node_test.test)("opaque vs cooked vs lossless — path segment", () => {
|
|
9
|
+
const t = require_compile.compile("/files{/path}");
|
|
10
|
+
const url = "/files/a%2Fb";
|
|
11
|
+
const mOpaque = t.match(url, { encoding: "opaque" });
|
|
12
|
+
const mCooked = t.match(url, { encoding: "cooked" });
|
|
13
|
+
const mLoss = t.match(url, { encoding: "lossless" });
|
|
14
|
+
if (!mOpaque || !mCooked || !mLoss) throw new Error("match failed");
|
|
15
|
+
(0, node_assert_strict.deepStrictEqual)(mOpaque.vars.path, "a%2Fb");
|
|
16
|
+
(0, node_assert_strict.deepStrictEqual)(mCooked.vars.path, "a/b");
|
|
17
|
+
(0, node_assert_strict.deepStrictEqual)(mLoss.vars.path.raw, "a%2Fb");
|
|
18
|
+
(0, node_assert_strict.deepStrictEqual)(mLoss.vars.path.decoded, "a/b");
|
|
19
|
+
});
|
|
20
|
+
(0, node_test.test)("opaque vs cooked vs lossless — query (named, non-explode)", () => {
|
|
21
|
+
const t = require_compile.compile("/s{?q}");
|
|
22
|
+
const url = "/s?q=a%20b";
|
|
23
|
+
const mo = t.match(url, { encoding: "opaque" });
|
|
24
|
+
const mc = t.match(url, { encoding: "cooked" });
|
|
25
|
+
const ml = t.match(url, { encoding: "lossless" });
|
|
26
|
+
if (!mo || !mc || !ml) throw new Error("match failed");
|
|
27
|
+
(0, node_assert_strict.deepStrictEqual)(mo.vars.q, "a%20b");
|
|
28
|
+
(0, node_assert_strict.deepStrictEqual)(mc.vars.q, "a b");
|
|
29
|
+
(0, node_assert_strict.deepStrictEqual)(ml.vars.q.raw, "a%20b");
|
|
30
|
+
(0, node_assert_strict.deepStrictEqual)(ml.vars.q.decoded, "a b");
|
|
31
|
+
});
|
|
32
|
+
(0, node_test.test)("opaque vs cooked — fragment (# operator)", () => {
|
|
33
|
+
const t = require_compile.compile("{#frag}");
|
|
34
|
+
const url = "#a%2Fb%23c";
|
|
35
|
+
const mo = t.match(url, { encoding: "opaque" });
|
|
36
|
+
const mc = t.match(url, { encoding: "cooked" });
|
|
37
|
+
if (!mo || !mc) throw new Error("match failed");
|
|
38
|
+
(0, node_assert_strict.deepStrictEqual)(mo.vars.frag, "a%2Fb%23c");
|
|
39
|
+
(0, node_assert_strict.deepStrictEqual)(mc.vars.frag, "a/b#c");
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
(0, node_test.describe)("round-trip behavior", () => {
|
|
43
|
+
(0, node_test.test)("round-trip: opaque always byte-equal; cooked not guaranteed on non-canonical URLs", () => {
|
|
44
|
+
{
|
|
45
|
+
const t1 = require_compile.compile("/repos{/owner,repo}{?q,lang}");
|
|
46
|
+
const url1 = "/repos/alice/hello%2Fworld?q=a%20b&lang=en";
|
|
47
|
+
const m1 = t1.match(url1, { encoding: "opaque" });
|
|
48
|
+
if (!m1) throw new Error("opaque match failed");
|
|
49
|
+
(0, node_assert_strict.deepStrictEqual)(t1.expand(m1.vars), url1);
|
|
50
|
+
}
|
|
51
|
+
{
|
|
52
|
+
const t2 = require_compile.compile("/files{/path}");
|
|
53
|
+
const url2 = "/files/a%252Fb";
|
|
54
|
+
const m2 = t2.match(url2, { encoding: "cooked" });
|
|
55
|
+
if (!m2) throw new Error("cooked match failed");
|
|
56
|
+
(0, node_assert_strict.notDeepStrictEqual)(t2.expand(m2.vars), url2);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
(0, node_test.test)("semantic round-trip holds for cooked", () => {
|
|
60
|
+
const t = require_compile.compile("/u{/id}{?q}");
|
|
61
|
+
const vars = {
|
|
62
|
+
id: "a/b",
|
|
63
|
+
q: "x y"
|
|
64
|
+
};
|
|
65
|
+
const url = t.expand(vars);
|
|
66
|
+
const mc = t.match(url, { encoding: "cooked" });
|
|
67
|
+
if (!mc) throw new Error("cooked match failed");
|
|
68
|
+
(0, node_assert_strict.deepStrictEqual)(mc.vars, vars);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
(0, node_test.describe)("lossless mode", () => {
|
|
72
|
+
(0, node_test.test)("lossless returns both forms and can re-expand either (non-canonical source)", () => {
|
|
73
|
+
const t = require_compile.compile("/doc{/path}{?q}");
|
|
74
|
+
const nonCanonicalUrl = "/doc/a%252Fb?q=x%2520y";
|
|
75
|
+
const ml = t.match(nonCanonicalUrl, { encoding: "lossless" });
|
|
76
|
+
if (!ml) throw new Error("lossless match failed");
|
|
77
|
+
const { path, q } = ml.vars;
|
|
78
|
+
(0, node_assert_strict.deepStrictEqual)(path.raw, "a%252Fb");
|
|
79
|
+
(0, node_assert_strict.deepStrictEqual)(path.decoded, "a%2Fb");
|
|
80
|
+
(0, node_assert_strict.deepStrictEqual)(q.raw, "x%2520y");
|
|
81
|
+
(0, node_assert_strict.deepStrictEqual)(q.decoded, "x%20y");
|
|
82
|
+
const urlFromRaw = t.expand({
|
|
83
|
+
path: path.raw,
|
|
84
|
+
q: q.raw
|
|
85
|
+
});
|
|
86
|
+
const urlFromDec = t.expand({
|
|
87
|
+
path: path.decoded,
|
|
88
|
+
q: q.decoded
|
|
89
|
+
});
|
|
90
|
+
(0, node_assert_strict.deepStrictEqual)(urlFromRaw, nonCanonicalUrl);
|
|
91
|
+
(0, node_assert_strict.notDeepStrictEqual)(urlFromDec, nonCanonicalUrl);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
(0, node_test.describe)("strict mode", () => {
|
|
95
|
+
(0, node_test.test)("strict mode: bad percent triplet fails; non-strict tolerates", () => {
|
|
96
|
+
const t = require_compile.compile("/x{/id}");
|
|
97
|
+
const malformedPctTriplet = "/x/%GZ";
|
|
98
|
+
const mStrict = t.match(malformedPctTriplet, { encoding: "opaque" });
|
|
99
|
+
(0, node_assert_strict.deepStrictEqual)(mStrict, null);
|
|
100
|
+
const mLenient = t.match(malformedPctTriplet, {
|
|
101
|
+
encoding: "opaque",
|
|
102
|
+
strict: false
|
|
103
|
+
});
|
|
104
|
+
if (!mLenient) throw new Error("lenient match unexpectedly failed");
|
|
105
|
+
(0, node_assert_strict.deepStrictEqual)(mLenient.vars.id, "%GZ");
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
//#endregion
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { compile } from "./compile.js";
|
|
2
|
+
import { deepStrictEqual, notDeepStrictEqual } from "node:assert/strict";
|
|
3
|
+
import { describe, test } from "node:test";
|
|
4
|
+
|
|
5
|
+
//#region src/match.spec.ts
|
|
6
|
+
describe("encoding modes", () => {
|
|
7
|
+
test("opaque vs cooked vs lossless — path segment", () => {
|
|
8
|
+
const t = compile("/files{/path}");
|
|
9
|
+
const url = "/files/a%2Fb";
|
|
10
|
+
const mOpaque = t.match(url, { encoding: "opaque" });
|
|
11
|
+
const mCooked = t.match(url, { encoding: "cooked" });
|
|
12
|
+
const mLoss = t.match(url, { encoding: "lossless" });
|
|
13
|
+
if (!mOpaque || !mCooked || !mLoss) throw new Error("match failed");
|
|
14
|
+
deepStrictEqual(mOpaque.vars.path, "a%2Fb");
|
|
15
|
+
deepStrictEqual(mCooked.vars.path, "a/b");
|
|
16
|
+
deepStrictEqual(mLoss.vars.path.raw, "a%2Fb");
|
|
17
|
+
deepStrictEqual(mLoss.vars.path.decoded, "a/b");
|
|
18
|
+
});
|
|
19
|
+
test("opaque vs cooked vs lossless — query (named, non-explode)", () => {
|
|
20
|
+
const t = compile("/s{?q}");
|
|
21
|
+
const url = "/s?q=a%20b";
|
|
22
|
+
const mo = t.match(url, { encoding: "opaque" });
|
|
23
|
+
const mc = t.match(url, { encoding: "cooked" });
|
|
24
|
+
const ml = t.match(url, { encoding: "lossless" });
|
|
25
|
+
if (!mo || !mc || !ml) throw new Error("match failed");
|
|
26
|
+
deepStrictEqual(mo.vars.q, "a%20b");
|
|
27
|
+
deepStrictEqual(mc.vars.q, "a b");
|
|
28
|
+
deepStrictEqual(ml.vars.q.raw, "a%20b");
|
|
29
|
+
deepStrictEqual(ml.vars.q.decoded, "a b");
|
|
30
|
+
});
|
|
31
|
+
test("opaque vs cooked — fragment (# operator)", () => {
|
|
32
|
+
const t = compile("{#frag}");
|
|
33
|
+
const url = "#a%2Fb%23c";
|
|
34
|
+
const mo = t.match(url, { encoding: "opaque" });
|
|
35
|
+
const mc = t.match(url, { encoding: "cooked" });
|
|
36
|
+
if (!mo || !mc) throw new Error("match failed");
|
|
37
|
+
deepStrictEqual(mo.vars.frag, "a%2Fb%23c");
|
|
38
|
+
deepStrictEqual(mc.vars.frag, "a/b#c");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("round-trip behavior", () => {
|
|
42
|
+
test("round-trip: opaque always byte-equal; cooked not guaranteed on non-canonical URLs", () => {
|
|
43
|
+
{
|
|
44
|
+
const t1 = compile("/repos{/owner,repo}{?q,lang}");
|
|
45
|
+
const url1 = "/repos/alice/hello%2Fworld?q=a%20b&lang=en";
|
|
46
|
+
const m1 = t1.match(url1, { encoding: "opaque" });
|
|
47
|
+
if (!m1) throw new Error("opaque match failed");
|
|
48
|
+
deepStrictEqual(t1.expand(m1.vars), url1);
|
|
49
|
+
}
|
|
50
|
+
{
|
|
51
|
+
const t2 = compile("/files{/path}");
|
|
52
|
+
const url2 = "/files/a%252Fb";
|
|
53
|
+
const m2 = t2.match(url2, { encoding: "cooked" });
|
|
54
|
+
if (!m2) throw new Error("cooked match failed");
|
|
55
|
+
notDeepStrictEqual(t2.expand(m2.vars), url2);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
test("semantic round-trip holds for cooked", () => {
|
|
59
|
+
const t = compile("/u{/id}{?q}");
|
|
60
|
+
const vars = {
|
|
61
|
+
id: "a/b",
|
|
62
|
+
q: "x y"
|
|
63
|
+
};
|
|
64
|
+
const url = t.expand(vars);
|
|
65
|
+
const mc = t.match(url, { encoding: "cooked" });
|
|
66
|
+
if (!mc) throw new Error("cooked match failed");
|
|
67
|
+
deepStrictEqual(mc.vars, vars);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
describe("lossless mode", () => {
|
|
71
|
+
test("lossless returns both forms and can re-expand either (non-canonical source)", () => {
|
|
72
|
+
const t = compile("/doc{/path}{?q}");
|
|
73
|
+
const nonCanonicalUrl = "/doc/a%252Fb?q=x%2520y";
|
|
74
|
+
const ml = t.match(nonCanonicalUrl, { encoding: "lossless" });
|
|
75
|
+
if (!ml) throw new Error("lossless match failed");
|
|
76
|
+
const { path, q } = ml.vars;
|
|
77
|
+
deepStrictEqual(path.raw, "a%252Fb");
|
|
78
|
+
deepStrictEqual(path.decoded, "a%2Fb");
|
|
79
|
+
deepStrictEqual(q.raw, "x%2520y");
|
|
80
|
+
deepStrictEqual(q.decoded, "x%20y");
|
|
81
|
+
const urlFromRaw = t.expand({
|
|
82
|
+
path: path.raw,
|
|
83
|
+
q: q.raw
|
|
84
|
+
});
|
|
85
|
+
const urlFromDec = t.expand({
|
|
86
|
+
path: path.decoded,
|
|
87
|
+
q: q.decoded
|
|
88
|
+
});
|
|
89
|
+
deepStrictEqual(urlFromRaw, nonCanonicalUrl);
|
|
90
|
+
notDeepStrictEqual(urlFromDec, nonCanonicalUrl);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
describe("strict mode", () => {
|
|
94
|
+
test("strict mode: bad percent triplet fails; non-strict tolerates", () => {
|
|
95
|
+
const t = compile("/x{/id}");
|
|
96
|
+
const malformedPctTriplet = "/x/%GZ";
|
|
97
|
+
const mStrict = t.match(malformedPctTriplet, { encoding: "opaque" });
|
|
98
|
+
deepStrictEqual(mStrict, null);
|
|
99
|
+
const mLenient = t.match(malformedPctTriplet, {
|
|
100
|
+
encoding: "opaque",
|
|
101
|
+
strict: false
|
|
102
|
+
});
|
|
103
|
+
if (!mLenient) throw new Error("lenient match unexpectedly failed");
|
|
104
|
+
deepStrictEqual(mLenient.vars.id, "%GZ");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
//#endregion
|
package/dist/parser.cjs
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
const require_error = require('./error.cjs');
|
|
2
|
+
|
|
3
|
+
//#region src/parser.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parse a RFC 6570 URI template string into an AST.
|
|
6
|
+
*
|
|
7
|
+
* @param template - The URI template string to parse
|
|
8
|
+
* @returns The parsed template AST
|
|
9
|
+
* @throws {ParseError} If the template syntax is invalid
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Parser guarantees:
|
|
13
|
+
* - Balanced braces: every '{' has a matching '}' or throws ParseError
|
|
14
|
+
* - Operator is one of "", "+", "#", ".", "/", ";", "?", "&"
|
|
15
|
+
* - VarSpec list: "name[:prefix][*]" items separated by ','
|
|
16
|
+
*
|
|
17
|
+
* We avoid regex for correctness and slice the source directly to keep
|
|
18
|
+
* raw segments intact for later matching.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const ast = parse("{+path}/here");
|
|
23
|
+
* // Returns AST with expression (op: "+", vars: [{name: "path"}]) and literal ("/here") nodes
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const ast = parse("/repos{/owner,repo}{?q,lang}");
|
|
29
|
+
* // Returns AST with literal and two expression nodes
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
function parse(template) {
|
|
33
|
+
const nodes = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
const pushLiteral = (start, end) => {
|
|
36
|
+
if (end > start) nodes.push({
|
|
37
|
+
kind: "literal",
|
|
38
|
+
value: template.slice(start, end),
|
|
39
|
+
start,
|
|
40
|
+
end
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
while (i < template.length) {
|
|
44
|
+
const litStart = i;
|
|
45
|
+
while (i < template.length && template[i] !== "{") i++;
|
|
46
|
+
pushLiteral(litStart, i);
|
|
47
|
+
if (i >= template.length) break;
|
|
48
|
+
const exprStart = i;
|
|
49
|
+
i++;
|
|
50
|
+
if (i >= template.length) throw new require_error.ParseError("Unclosed expression", exprStart);
|
|
51
|
+
const opChar = "+#./;?&".includes(template[i]) ? template[i++] : "";
|
|
52
|
+
const vars = [];
|
|
53
|
+
const readName = () => {
|
|
54
|
+
const start = i;
|
|
55
|
+
while (i < template.length && template[i] !== "}" && template[i] !== "," && template[i] !== ":" && template[i] !== "*") i++;
|
|
56
|
+
if (i === start) throw new require_error.ParseError("Empty variable name", i);
|
|
57
|
+
return template.slice(start, i);
|
|
58
|
+
};
|
|
59
|
+
while (true) {
|
|
60
|
+
const name = readName();
|
|
61
|
+
let explode = false;
|
|
62
|
+
let prefix;
|
|
63
|
+
if (template[i] === ":") {
|
|
64
|
+
i++;
|
|
65
|
+
const start = i;
|
|
66
|
+
while (i < template.length && /[0-9]/.test(template[i])) i++;
|
|
67
|
+
if (i === start) throw new require_error.ParseError("Expected prefix length", i);
|
|
68
|
+
prefix = parseInt(template.slice(start, i), 10);
|
|
69
|
+
if (!(prefix >= 0)) throw new require_error.ParseError("Invalid prefix length", start);
|
|
70
|
+
}
|
|
71
|
+
if (template[i] === "*") {
|
|
72
|
+
explode = true;
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
vars.push({
|
|
76
|
+
name,
|
|
77
|
+
explode,
|
|
78
|
+
prefix
|
|
79
|
+
});
|
|
80
|
+
if (template[i] === ",") {
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (template[i] === "}") {
|
|
85
|
+
i++;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
throw new require_error.ParseError("Unexpected character in expression", i);
|
|
89
|
+
}
|
|
90
|
+
nodes.push({
|
|
91
|
+
kind: "expression",
|
|
92
|
+
op: opChar,
|
|
93
|
+
vars,
|
|
94
|
+
start: exprStart,
|
|
95
|
+
end: i
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return { nodes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
exports.parse = parse;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { TemplateAst } from "./ast.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/parser.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a RFC 6570 URI template string into an AST.
|
|
7
|
+
*
|
|
8
|
+
* @param template - The URI template string to parse
|
|
9
|
+
* @returns The parsed template AST
|
|
10
|
+
* @throws {ParseError} If the template syntax is invalid
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Parser guarantees:
|
|
14
|
+
* - Balanced braces: every '{' has a matching '}' or throws ParseError
|
|
15
|
+
* - Operator is one of "", "+", "#", ".", "/", ";", "?", "&"
|
|
16
|
+
* - VarSpec list: "name[:prefix][*]" items separated by ','
|
|
17
|
+
*
|
|
18
|
+
* We avoid regex for correctness and slice the source directly to keep
|
|
19
|
+
* raw segments intact for later matching.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* const ast = parse("{+path}/here");
|
|
24
|
+
* // Returns AST with expression (op: "+", vars: [{name: "path"}]) and literal ("/here") nodes
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const ast = parse("/repos{/owner,repo}{?q,lang}");
|
|
30
|
+
* // Returns AST with literal and two expression nodes
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
declare function parse(template: string): TemplateAst;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { parse };
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { TemplateAst } from "./ast.js";
|
|
2
|
+
|
|
3
|
+
//#region src/parser.d.ts
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse a RFC 6570 URI template string into an AST.
|
|
7
|
+
*
|
|
8
|
+
* @param template - The URI template string to parse
|
|
9
|
+
* @returns The parsed template AST
|
|
10
|
+
* @throws {ParseError} If the template syntax is invalid
|
|
11
|
+
*
|
|
12
|
+
* @remarks
|
|
13
|
+
* Parser guarantees:
|
|
14
|
+
* - Balanced braces: every '{' has a matching '}' or throws ParseError
|
|
15
|
+
* - Operator is one of "", "+", "#", ".", "/", ";", "?", "&"
|
|
16
|
+
* - VarSpec list: "name[:prefix][*]" items separated by ','
|
|
17
|
+
*
|
|
18
|
+
* We avoid regex for correctness and slice the source directly to keep
|
|
19
|
+
* raw segments intact for later matching.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* ```typescript
|
|
23
|
+
* const ast = parse("{+path}/here");
|
|
24
|
+
* // Returns AST with expression (op: "+", vars: [{name: "path"}]) and literal ("/here") nodes
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const ast = parse("/repos{/owner,repo}{?q,lang}");
|
|
30
|
+
* // Returns AST with literal and two expression nodes
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
declare function parse(template: string): TemplateAst;
|
|
34
|
+
//#endregion
|
|
35
|
+
export { parse };
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ParseError } from "./error.js";
|
|
2
|
+
|
|
3
|
+
//#region src/parser.ts
|
|
4
|
+
/**
|
|
5
|
+
* Parse a RFC 6570 URI template string into an AST.
|
|
6
|
+
*
|
|
7
|
+
* @param template - The URI template string to parse
|
|
8
|
+
* @returns The parsed template AST
|
|
9
|
+
* @throws {ParseError} If the template syntax is invalid
|
|
10
|
+
*
|
|
11
|
+
* @remarks
|
|
12
|
+
* Parser guarantees:
|
|
13
|
+
* - Balanced braces: every '{' has a matching '}' or throws ParseError
|
|
14
|
+
* - Operator is one of "", "+", "#", ".", "/", ";", "?", "&"
|
|
15
|
+
* - VarSpec list: "name[:prefix][*]" items separated by ','
|
|
16
|
+
*
|
|
17
|
+
* We avoid regex for correctness and slice the source directly to keep
|
|
18
|
+
* raw segments intact for later matching.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```typescript
|
|
22
|
+
* const ast = parse("{+path}/here");
|
|
23
|
+
* // Returns AST with expression (op: "+", vars: [{name: "path"}]) and literal ("/here") nodes
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* ```typescript
|
|
28
|
+
* const ast = parse("/repos{/owner,repo}{?q,lang}");
|
|
29
|
+
* // Returns AST with literal and two expression nodes
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
function parse(template) {
|
|
33
|
+
const nodes = [];
|
|
34
|
+
let i = 0;
|
|
35
|
+
const pushLiteral = (start, end) => {
|
|
36
|
+
if (end > start) nodes.push({
|
|
37
|
+
kind: "literal",
|
|
38
|
+
value: template.slice(start, end),
|
|
39
|
+
start,
|
|
40
|
+
end
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
while (i < template.length) {
|
|
44
|
+
const litStart = i;
|
|
45
|
+
while (i < template.length && template[i] !== "{") i++;
|
|
46
|
+
pushLiteral(litStart, i);
|
|
47
|
+
if (i >= template.length) break;
|
|
48
|
+
const exprStart = i;
|
|
49
|
+
i++;
|
|
50
|
+
if (i >= template.length) throw new ParseError("Unclosed expression", exprStart);
|
|
51
|
+
const opChar = "+#./;?&".includes(template[i]) ? template[i++] : "";
|
|
52
|
+
const vars = [];
|
|
53
|
+
const readName = () => {
|
|
54
|
+
const start = i;
|
|
55
|
+
while (i < template.length && template[i] !== "}" && template[i] !== "," && template[i] !== ":" && template[i] !== "*") i++;
|
|
56
|
+
if (i === start) throw new ParseError("Empty variable name", i);
|
|
57
|
+
return template.slice(start, i);
|
|
58
|
+
};
|
|
59
|
+
while (true) {
|
|
60
|
+
const name = readName();
|
|
61
|
+
let explode = false;
|
|
62
|
+
let prefix;
|
|
63
|
+
if (template[i] === ":") {
|
|
64
|
+
i++;
|
|
65
|
+
const start = i;
|
|
66
|
+
while (i < template.length && /[0-9]/.test(template[i])) i++;
|
|
67
|
+
if (i === start) throw new ParseError("Expected prefix length", i);
|
|
68
|
+
prefix = parseInt(template.slice(start, i), 10);
|
|
69
|
+
if (!(prefix >= 0)) throw new ParseError("Invalid prefix length", start);
|
|
70
|
+
}
|
|
71
|
+
if (template[i] === "*") {
|
|
72
|
+
explode = true;
|
|
73
|
+
i++;
|
|
74
|
+
}
|
|
75
|
+
vars.push({
|
|
76
|
+
name,
|
|
77
|
+
explode,
|
|
78
|
+
prefix
|
|
79
|
+
});
|
|
80
|
+
if (template[i] === ",") {
|
|
81
|
+
i++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (template[i] === "}") {
|
|
85
|
+
i++;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
throw new ParseError("Unexpected character in expression", i);
|
|
89
|
+
}
|
|
90
|
+
nodes.push({
|
|
91
|
+
kind: "expression",
|
|
92
|
+
op: opChar,
|
|
93
|
+
vars,
|
|
94
|
+
start: exprStart,
|
|
95
|
+
end: i
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return { nodes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
//#endregion
|
|
102
|
+
export { parse };
|