@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/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
@@ -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 };
@@ -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 };