@qrxcode/js 0.4.0 → 0.5.0

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/README.md CHANGED
@@ -18,9 +18,14 @@ QRX works with:
18
18
  A normal QR code still contains a normal URL.
19
19
 
20
20
  QRX-compatible applications resolve the URL,
21
- discover machine-readable flows from the HTML `<head>`,
21
+ discover machine-readable flows,
22
22
  and let the application decide what to do next.
23
23
 
24
+ Discovery currently supports:
25
+
26
+ - HTML `<link>` declarations
27
+ - HTTP `Link` response headers
28
+
24
29
  This works especially well with podcast websites,
25
30
  because many podcast sites already expose one or multiple feeds.
26
31
 
@@ -100,7 +105,7 @@ Version `0.4.0` adds a new flow category:
100
105
  flowType: "qrx"
101
106
  ```
102
107
 
103
- A QRX flow is discovered from an explicit HTML `<link>` declaration:
108
+ A QRX flow is discovered from an explicit HTML declaration:
104
109
 
105
110
  ```html
106
111
  <link
@@ -121,7 +126,7 @@ These are valid:
121
126
 
122
127
  ```html
123
128
  <link rel="qrx" type="application/qrx+json" href="/qrx.json">
124
- <link rel="qrx" type="text/html" href="/demo.html">
129
+ <link rel="qrx" type="text/html" href="/qrx-demo.html">
125
130
  <link rel="qrx" type="application/json" href="/manifest.json">
126
131
  ```
127
132
 
@@ -142,6 +147,37 @@ declared flow.
142
147
 
143
148
  Applications decide what to do with discovered QRX flows.
144
149
 
150
+ ## New in 0.5.0
151
+
152
+ Version `0.5.0` adds public HTTP `Link` header discovery.
153
+
154
+ QRX can now discover flows from:
155
+
156
+ * HTML `<link>` tags
157
+ * HTTP `Link` response headers
158
+
159
+ Example:
160
+
161
+ ```http
162
+ Link: </qrx-demo/>; rel="qrx"; type="text/html"
163
+ ```
164
+
165
+ HTTP `Link` header discovery lets a server expose QRX flows
166
+ at the HTTP layer,
167
+ without placing the QRX declaration inside the HTML source.
168
+
169
+ HTTP header flows are returned before HTML flows.
170
+
171
+ This is deterministic discovery order only.
172
+
173
+ It is not ranking, priority, override, or deduplication.
174
+
175
+ If the same flow appears in both HTTP headers and HTML,
176
+ both flows are returned.
177
+
178
+ QRX discovers recognized flows.
179
+ Applications decide what to do with them.
180
+
145
181
  ## Install
146
182
 
147
183
  ```bash
@@ -164,6 +200,12 @@ console.log(result.flows);
164
200
 
165
201
  ```js
166
202
  [
203
+ {
204
+ flowType: "qrx",
205
+ rel: "qrx",
206
+ href: "https://example.com/qrx-demo/",
207
+ type: "text/html"
208
+ },
167
209
  {
168
210
  flowType: "feed",
169
211
  rel: "alternate",
@@ -233,7 +275,7 @@ const qrxFlows = selectFlowsByFlowType(
233
275
 
234
276
  ## Supported discovery methods
235
277
 
236
- Feed flow:
278
+ Feed flow from HTML:
237
279
 
238
280
  ```html
239
281
  <link
@@ -242,21 +284,13 @@ Feed flow:
242
284
  href="/feed.xml">
243
285
  ```
244
286
 
245
- ```html
246
- <link
247
- rel="alternate"
248
- type="application/atom+xml"
249
- href="/atom.xml">
250
- ```
287
+ Feed flow from HTTP:
251
288
 
252
- ```html
253
- <link
254
- rel="alternate"
255
- type="application/feed+json"
256
- href="/feed.json">
289
+ ```http
290
+ Link: </feed.xml>; rel="alternate"; type="application/rss+xml"
257
291
  ```
258
292
 
259
- QRX flow:
293
+ QRX flow from HTML:
260
294
 
261
295
  ```html
262
296
  <link
@@ -265,6 +299,12 @@ QRX flow:
265
299
  href="/qrx.json">
266
300
  ```
267
301
 
302
+ QRX flow from HTTP:
303
+
304
+ ```http
305
+ Link: </qrx-demo/>; rel="qrx"; type="text/html"
306
+ ```
307
+
268
308
  ## Philosophy
269
309
 
270
310
  QRX does not change QR codes.
package/dist/index.cjs CHANGED
@@ -20,7 +20,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ detectFlowCandidate: () => detectFlowCandidate,
23
24
  detectFlows: () => detectFlows,
25
+ detectFlowsFromLinkHeader: () => detectFlowsFromLinkHeader,
24
26
  resolveQRX: () => resolveQRX,
25
27
  selectFlowsByFlowType: () => selectFlowsByFlowType
26
28
  });
@@ -33,45 +35,101 @@ var FEED_TYPES = /* @__PURE__ */ new Set([
33
35
  "application/feed+json"
34
36
  ]);
35
37
  function detectFlows(html, sourceUrl) {
36
- const flows = [];
38
+ const candidates = extractHtmlLinkCandidates(html);
39
+ return candidates.map(
40
+ (candidate) => detectFlowCandidate(candidate, sourceUrl)
41
+ ).filter((flow) => flow !== null);
42
+ }
43
+ function detectFlowsFromLinkHeader(linkHeader, sourceUrl) {
44
+ if (!linkHeader) {
45
+ return [];
46
+ }
47
+ const candidates = extractHttpLinkCandidates(linkHeader);
48
+ return candidates.map(
49
+ (candidate) => detectFlowCandidate(candidate, sourceUrl)
50
+ ).filter((flow) => flow !== null);
51
+ }
52
+ function detectFlowCandidate(candidate, sourceUrl) {
53
+ const { rel, type, href } = candidate;
54
+ if (!rel || !href) {
55
+ return null;
56
+ }
57
+ const normalizedRel = rel.trim();
58
+ if (!normalizedRel) {
59
+ return null;
60
+ }
61
+ const resolvedHref = new URL(
62
+ href,
63
+ sourceUrl
64
+ ).toString();
65
+ if (normalizedRel === "qrx") {
66
+ if (!type) {
67
+ return null;
68
+ }
69
+ return {
70
+ flowType: "qrx",
71
+ rel: normalizedRel,
72
+ href: resolvedHref,
73
+ type: type.trim().toLowerCase()
74
+ };
75
+ }
76
+ if (!type) {
77
+ return null;
78
+ }
79
+ const normalizedType = type.trim().toLowerCase();
80
+ if (!hasRel(normalizedRel, "alternate")) {
81
+ return null;
82
+ }
83
+ if (!FEED_TYPES.has(normalizedType)) {
84
+ return null;
85
+ }
86
+ return {
87
+ flowType: "feed",
88
+ rel: normalizedRel,
89
+ href: resolvedHref,
90
+ type: normalizedType
91
+ };
92
+ }
93
+ function extractHtmlLinkCandidates(html) {
37
94
  const linkTagRegex = /<link\s+[^>]*>/gi;
38
95
  const tags = html.match(linkTagRegex) || [];
39
- for (const tag of tags) {
40
- const rel = getAttribute(tag, "rel");
41
- const type = getAttribute(tag, "type");
42
- const href = getAttribute(tag, "href");
43
- if (!rel || !type || !href) {
44
- continue;
45
- }
46
- const normalizedRel = rel.trim();
47
- const normalizedType = type.trim().toLowerCase();
48
- const resolvedHref = new URL(
49
- href,
50
- sourceUrl
51
- ).toString();
52
- if (normalizedRel === "qrx") {
53
- flows.push({
54
- flowType: "qrx",
55
- rel: normalizedRel,
56
- href: resolvedHref,
57
- type: normalizedType
58
- });
59
- continue;
60
- }
61
- if (!hasRel(normalizedRel, "alternate")) {
62
- continue;
96
+ return tags.map((tag) => ({
97
+ rel: getAttribute(tag, "rel"),
98
+ type: getAttribute(tag, "type"),
99
+ href: getAttribute(tag, "href")
100
+ }));
101
+ }
102
+ function extractHttpLinkCandidates(linkHeader) {
103
+ return splitLinkHeader(linkHeader).map(parseHttpLinkValue).filter(
104
+ (candidate) => candidate !== null
105
+ );
106
+ }
107
+ function parseHttpLinkValue(value) {
108
+ const hrefMatch = value.match(/^\s*<([^>]+)>/);
109
+ if (!hrefMatch) {
110
+ return null;
111
+ }
112
+ const candidate = {
113
+ href: hrefMatch[1],
114
+ rel: null,
115
+ type: null
116
+ };
117
+ const paramRegex = /;\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)"/g;
118
+ let match;
119
+ while ((match = paramRegex.exec(value)) !== null) {
120
+ const name = match[1].toLowerCase();
121
+ const paramValue = match[2];
122
+ if (name === "rel") {
123
+ candidate.rel = paramValue;
63
124
  }
64
- if (!FEED_TYPES.has(normalizedType)) {
65
- continue;
125
+ if (name === "type") {
126
+ candidate.type = paramValue;
66
127
  }
67
- flows.push({
68
- flowType: "feed",
69
- rel: normalizedRel,
70
- href: resolvedHref,
71
- type: normalizedType
72
- });
73
128
  }
74
- return flows;
129
+ return candidate;
130
+ }
131
+ function splitLinkHeader(linkHeader) {
132
+ return linkHeader.split(/,\s*(?=<)/).map((part) => part.trim()).filter(Boolean);
75
133
  }
76
134
  function getAttribute(tag, attribute) {
77
135
  const regex = new RegExp(
@@ -88,13 +146,21 @@ function hasRel(rel, expected) {
88
146
  // src/resolve.ts
89
147
  async function resolveQRX(url) {
90
148
  const response = await fetch(url);
149
+ const sourceUrl = response.url;
150
+ const headerFlows = detectFlowsFromLinkHeader(
151
+ response.headers.get("link"),
152
+ sourceUrl
153
+ );
91
154
  const html = await response.text();
92
- const flows = detectFlows(
155
+ const htmlFlows = detectFlows(
93
156
  html,
94
- response.url
157
+ sourceUrl
95
158
  );
96
159
  return {
97
- flows
160
+ flows: [
161
+ ...headerFlows,
162
+ ...htmlFlows
163
+ ]
98
164
  };
99
165
  }
100
166
 
@@ -108,7 +174,9 @@ function selectFlowsByFlowType(flows, preferredFlowTypes) {
108
174
  }
109
175
  // Annotate the CommonJS export names for ESM import in node:
110
176
  0 && (module.exports = {
177
+ detectFlowCandidate,
111
178
  detectFlows,
179
+ detectFlowsFromLinkHeader,
112
180
  resolveQRX,
113
181
  selectFlowsByFlowType
114
182
  });
package/dist/index.d.cts CHANGED
@@ -9,10 +9,17 @@ interface QRXResult {
9
9
  flows: Flow[];
10
10
  }
11
11
 
12
+ interface LinkCandidate {
13
+ rel: string | null;
14
+ href: string | null;
15
+ type: string | null;
16
+ }
12
17
  declare function detectFlows(html: string, sourceUrl: string): Flow[];
18
+ declare function detectFlowsFromLinkHeader(linkHeader: string | null, sourceUrl: string): Flow[];
19
+ declare function detectFlowCandidate(candidate: LinkCandidate, sourceUrl: string): Flow | null;
13
20
 
14
21
  declare function resolveQRX(url: string): Promise<QRXResult>;
15
22
 
16
23
  declare function selectFlowsByFlowType(flows: Flow[], preferredFlowTypes: FlowType[]): Flow[];
17
24
 
18
- export { type Flow, type FlowType, type QRXResult, detectFlows, resolveQRX, selectFlowsByFlowType };
25
+ export { type Flow, type FlowType, type QRXResult, detectFlowCandidate, detectFlows, detectFlowsFromLinkHeader, resolveQRX, selectFlowsByFlowType };
package/dist/index.d.ts CHANGED
@@ -9,10 +9,17 @@ interface QRXResult {
9
9
  flows: Flow[];
10
10
  }
11
11
 
12
+ interface LinkCandidate {
13
+ rel: string | null;
14
+ href: string | null;
15
+ type: string | null;
16
+ }
12
17
  declare function detectFlows(html: string, sourceUrl: string): Flow[];
18
+ declare function detectFlowsFromLinkHeader(linkHeader: string | null, sourceUrl: string): Flow[];
19
+ declare function detectFlowCandidate(candidate: LinkCandidate, sourceUrl: string): Flow | null;
13
20
 
14
21
  declare function resolveQRX(url: string): Promise<QRXResult>;
15
22
 
16
23
  declare function selectFlowsByFlowType(flows: Flow[], preferredFlowTypes: FlowType[]): Flow[];
17
24
 
18
- export { type Flow, type FlowType, type QRXResult, detectFlows, resolveQRX, selectFlowsByFlowType };
25
+ export { type Flow, type FlowType, type QRXResult, detectFlowCandidate, detectFlows, detectFlowsFromLinkHeader, resolveQRX, selectFlowsByFlowType };
package/dist/index.js CHANGED
@@ -5,45 +5,101 @@ var FEED_TYPES = /* @__PURE__ */ new Set([
5
5
  "application/feed+json"
6
6
  ]);
7
7
  function detectFlows(html, sourceUrl) {
8
- const flows = [];
8
+ const candidates = extractHtmlLinkCandidates(html);
9
+ return candidates.map(
10
+ (candidate) => detectFlowCandidate(candidate, sourceUrl)
11
+ ).filter((flow) => flow !== null);
12
+ }
13
+ function detectFlowsFromLinkHeader(linkHeader, sourceUrl) {
14
+ if (!linkHeader) {
15
+ return [];
16
+ }
17
+ const candidates = extractHttpLinkCandidates(linkHeader);
18
+ return candidates.map(
19
+ (candidate) => detectFlowCandidate(candidate, sourceUrl)
20
+ ).filter((flow) => flow !== null);
21
+ }
22
+ function detectFlowCandidate(candidate, sourceUrl) {
23
+ const { rel, type, href } = candidate;
24
+ if (!rel || !href) {
25
+ return null;
26
+ }
27
+ const normalizedRel = rel.trim();
28
+ if (!normalizedRel) {
29
+ return null;
30
+ }
31
+ const resolvedHref = new URL(
32
+ href,
33
+ sourceUrl
34
+ ).toString();
35
+ if (normalizedRel === "qrx") {
36
+ if (!type) {
37
+ return null;
38
+ }
39
+ return {
40
+ flowType: "qrx",
41
+ rel: normalizedRel,
42
+ href: resolvedHref,
43
+ type: type.trim().toLowerCase()
44
+ };
45
+ }
46
+ if (!type) {
47
+ return null;
48
+ }
49
+ const normalizedType = type.trim().toLowerCase();
50
+ if (!hasRel(normalizedRel, "alternate")) {
51
+ return null;
52
+ }
53
+ if (!FEED_TYPES.has(normalizedType)) {
54
+ return null;
55
+ }
56
+ return {
57
+ flowType: "feed",
58
+ rel: normalizedRel,
59
+ href: resolvedHref,
60
+ type: normalizedType
61
+ };
62
+ }
63
+ function extractHtmlLinkCandidates(html) {
9
64
  const linkTagRegex = /<link\s+[^>]*>/gi;
10
65
  const tags = html.match(linkTagRegex) || [];
11
- for (const tag of tags) {
12
- const rel = getAttribute(tag, "rel");
13
- const type = getAttribute(tag, "type");
14
- const href = getAttribute(tag, "href");
15
- if (!rel || !type || !href) {
16
- continue;
17
- }
18
- const normalizedRel = rel.trim();
19
- const normalizedType = type.trim().toLowerCase();
20
- const resolvedHref = new URL(
21
- href,
22
- sourceUrl
23
- ).toString();
24
- if (normalizedRel === "qrx") {
25
- flows.push({
26
- flowType: "qrx",
27
- rel: normalizedRel,
28
- href: resolvedHref,
29
- type: normalizedType
30
- });
31
- continue;
32
- }
33
- if (!hasRel(normalizedRel, "alternate")) {
34
- continue;
66
+ return tags.map((tag) => ({
67
+ rel: getAttribute(tag, "rel"),
68
+ type: getAttribute(tag, "type"),
69
+ href: getAttribute(tag, "href")
70
+ }));
71
+ }
72
+ function extractHttpLinkCandidates(linkHeader) {
73
+ return splitLinkHeader(linkHeader).map(parseHttpLinkValue).filter(
74
+ (candidate) => candidate !== null
75
+ );
76
+ }
77
+ function parseHttpLinkValue(value) {
78
+ const hrefMatch = value.match(/^\s*<([^>]+)>/);
79
+ if (!hrefMatch) {
80
+ return null;
81
+ }
82
+ const candidate = {
83
+ href: hrefMatch[1],
84
+ rel: null,
85
+ type: null
86
+ };
87
+ const paramRegex = /;\s*([a-zA-Z][a-zA-Z0-9_-]*)\s*=\s*"([^"]*)"/g;
88
+ let match;
89
+ while ((match = paramRegex.exec(value)) !== null) {
90
+ const name = match[1].toLowerCase();
91
+ const paramValue = match[2];
92
+ if (name === "rel") {
93
+ candidate.rel = paramValue;
35
94
  }
36
- if (!FEED_TYPES.has(normalizedType)) {
37
- continue;
95
+ if (name === "type") {
96
+ candidate.type = paramValue;
38
97
  }
39
- flows.push({
40
- flowType: "feed",
41
- rel: normalizedRel,
42
- href: resolvedHref,
43
- type: normalizedType
44
- });
45
98
  }
46
- return flows;
99
+ return candidate;
100
+ }
101
+ function splitLinkHeader(linkHeader) {
102
+ return linkHeader.split(/,\s*(?=<)/).map((part) => part.trim()).filter(Boolean);
47
103
  }
48
104
  function getAttribute(tag, attribute) {
49
105
  const regex = new RegExp(
@@ -60,13 +116,21 @@ function hasRel(rel, expected) {
60
116
  // src/resolve.ts
61
117
  async function resolveQRX(url) {
62
118
  const response = await fetch(url);
119
+ const sourceUrl = response.url;
120
+ const headerFlows = detectFlowsFromLinkHeader(
121
+ response.headers.get("link"),
122
+ sourceUrl
123
+ );
63
124
  const html = await response.text();
64
- const flows = detectFlows(
125
+ const htmlFlows = detectFlows(
65
126
  html,
66
- response.url
127
+ sourceUrl
67
128
  );
68
129
  return {
69
- flows
130
+ flows: [
131
+ ...headerFlows,
132
+ ...htmlFlows
133
+ ]
70
134
  };
71
135
  }
72
136
 
@@ -79,7 +143,9 @@ function selectFlowsByFlowType(flows, preferredFlowTypes) {
79
143
  );
80
144
  }
81
145
  export {
146
+ detectFlowCandidate,
82
147
  detectFlows,
148
+ detectFlowsFromLinkHeader,
83
149
  resolveQRX,
84
150
  selectFlowsByFlowType
85
151
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qrxcode/js",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "QRX flow discovery SDK for JavaScript.",
5
5
  "homepage": "https://qrx.dev",
6
6
  "repository": {