@qrxcode/js 0.3.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
 
@@ -30,6 +35,7 @@ and natural as following someone on social media.
30
35
  Examples of flows:
31
36
 
32
37
  - Feed flows
38
+ - QRX flows
33
39
 
34
40
  In QRX, a "flow" is a recognized machine-readable relationship
35
41
  that applications can discover and interact with.
@@ -42,7 +48,7 @@ Learn more at https://qrx.dev
42
48
 
43
49
  ## Breaking change in 0.3.0
44
50
 
45
- Version `0.3.0` introduces a breaking cleanup of feed flow classification.
51
+ Version `0.3.0` introduced a breaking cleanup of feed flow classification.
46
52
 
47
53
  Before `0.3.0`, RSS, Atom, and JSON Feed were represented as separate
48
54
  QRX flow types:
@@ -91,6 +97,87 @@ Migration:
91
97
 
92
98
  QRX discovers recognized flows. Applications decide what to do with them.
93
99
 
100
+ ## New in 0.4.0
101
+
102
+ Version `0.4.0` adds a new flow category:
103
+
104
+ ```js
105
+ flowType: "qrx"
106
+ ```
107
+
108
+ A QRX flow is discovered from an explicit HTML declaration:
109
+
110
+ ```html
111
+ <link
112
+ rel="qrx"
113
+ type="application/qrx+json"
114
+ href="/qrx.json">
115
+ ```
116
+
117
+ QRX flow discovery requires all of these:
118
+
119
+ * `rel="qrx"`
120
+ * `href`
121
+ * explicit `type`
122
+
123
+ For `qrx` flows, `rel` must be exactly `qrx`.
124
+
125
+ These are valid:
126
+
127
+ ```html
128
+ <link rel="qrx" type="application/qrx+json" href="/qrx.json">
129
+ <link rel="qrx" type="text/html" href="/qrx-demo.html">
130
+ <link rel="qrx" type="application/json" href="/manifest.json">
131
+ ```
132
+
133
+ These are not valid QRX flow declarations:
134
+
135
+ ```html
136
+ <link rel="alternate qrx" type="application/qrx+json" href="/qrx.json">
137
+ <link rel="qrx something" type="application/qrx+json" href="/qrx.json">
138
+ <link rel="notqrx" type="application/qrx+json" href="/qrx.json">
139
+ <link rel="qrx" href="/qrx.json">
140
+ ```
141
+
142
+ The `type` value can be any explicit media type, not only
143
+ `application/qrx+json`.
144
+
145
+ The package does not fetch or parse QRX payloads. It only discovers the
146
+ declared flow.
147
+
148
+ Applications decide what to do with discovered QRX flows.
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
+
94
181
  ## Install
95
182
 
96
183
  ```bash
@@ -103,7 +190,7 @@ npm install @qrxcode/js
103
190
  import { resolveQRX } from "@qrxcode/js";
104
191
 
105
192
  const result = await resolveQRX(
106
- "https://podnews.net"
193
+ "https://example.com"
107
194
  );
108
195
 
109
196
  console.log(result.flows);
@@ -113,17 +200,23 @@ console.log(result.flows);
113
200
 
114
201
  ```js
115
202
  [
203
+ {
204
+ flowType: "qrx",
205
+ rel: "qrx",
206
+ href: "https://example.com/qrx-demo/",
207
+ type: "text/html"
208
+ },
116
209
  {
117
210
  flowType: "feed",
118
211
  rel: "alternate",
119
- href: "https://podnews.net/rss",
212
+ href: "https://example.com/feed.xml",
120
213
  type: "application/rss+xml"
121
214
  },
122
215
  {
123
- flowType: "feed",
124
- rel: "alternate",
125
- href: "https://podnews.net/feed.json",
126
- type: "application/feed+json"
216
+ flowType: "qrx",
217
+ rel: "qrx",
218
+ href: "https://example.com/qrx.json",
219
+ type: "application/qrx+json"
127
220
  }
128
221
  ]
129
222
  ```
@@ -139,7 +232,7 @@ import {
139
232
  } from "@qrxcode/js";
140
233
 
141
234
  const result = await resolveQRX(
142
- "https://podnews.net"
235
+ "https://example.com"
143
236
  );
144
237
 
145
238
  const feeds = selectFlowsByFlowType(
@@ -160,29 +253,19 @@ const rssFeeds = result.flows.filter(
160
253
  );
161
254
  ```
162
255
 
163
- To select only Atom feeds:
164
-
165
- ```js
166
- const atomFeeds = result.flows.filter(
167
- (discoveredFlow) =>
168
- discoveredFlow.flowType === "feed" &&
169
- discoveredFlow.type === "application/atom+xml"
170
- );
171
- ```
172
-
173
- To select only JSON Feed feeds:
256
+ To select QRX flows:
174
257
 
175
258
  ```js
176
- const jsonFeeds = result.flows.filter(
177
- (discoveredFlow) =>
178
- discoveredFlow.flowType === "feed" &&
179
- discoveredFlow.type === "application/feed+json"
259
+ const qrxFlows = selectFlowsByFlowType(
260
+ result.flows,
261
+ ["qrx"]
180
262
  );
181
263
  ```
182
264
 
183
265
  ## Supported flow types
184
266
 
185
267
  * feed
268
+ * qrx
186
269
 
187
270
  ## Supported feed formats
188
271
 
@@ -192,6 +275,8 @@ const jsonFeeds = result.flows.filter(
192
275
 
193
276
  ## Supported discovery methods
194
277
 
278
+ Feed flow from HTML:
279
+
195
280
  ```html
196
281
  <link
197
282
  rel="alternate"
@@ -199,18 +284,25 @@ const jsonFeeds = result.flows.filter(
199
284
  href="/feed.xml">
200
285
  ```
201
286
 
202
- ```html
203
- <link
204
- rel="alternate"
205
- type="application/atom+xml"
206
- href="/atom.xml">
287
+ Feed flow from HTTP:
288
+
289
+ ```http
290
+ Link: </feed.xml>; rel="alternate"; type="application/rss+xml"
207
291
  ```
208
292
 
293
+ QRX flow from HTML:
294
+
209
295
  ```html
210
296
  <link
211
- rel="alternate"
212
- type="application/feed+json"
213
- href="/feed.json">
297
+ rel="qrx"
298
+ type="application/qrx+json"
299
+ href="/qrx.json">
300
+ ```
301
+
302
+ QRX flow from HTTP:
303
+
304
+ ```http
305
+ Link: </qrx-demo/>; rel="qrx"; type="text/html"
214
306
  ```
215
307
 
216
308
  ## Philosophy
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,32 +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
- if (!hasRel(normalizedRel, "alternate")) {
49
- 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;
50
124
  }
51
- if (!FEED_TYPES.has(normalizedType)) {
52
- continue;
125
+ if (name === "type") {
126
+ candidate.type = paramValue;
53
127
  }
54
- flows.push({
55
- flowType: "feed",
56
- rel: normalizedRel,
57
- href: new URL(href, sourceUrl).toString(),
58
- type: normalizedType
59
- });
60
128
  }
61
- return flows;
129
+ return candidate;
130
+ }
131
+ function splitLinkHeader(linkHeader) {
132
+ return linkHeader.split(/,\s*(?=<)/).map((part) => part.trim()).filter(Boolean);
62
133
  }
63
134
  function getAttribute(tag, attribute) {
64
135
  const regex = new RegExp(
@@ -75,13 +146,21 @@ function hasRel(rel, expected) {
75
146
  // src/resolve.ts
76
147
  async function resolveQRX(url) {
77
148
  const response = await fetch(url);
149
+ const sourceUrl = response.url;
150
+ const headerFlows = detectFlowsFromLinkHeader(
151
+ response.headers.get("link"),
152
+ sourceUrl
153
+ );
78
154
  const html = await response.text();
79
- const flows = detectFlows(
155
+ const htmlFlows = detectFlows(
80
156
  html,
81
- response.url
157
+ sourceUrl
82
158
  );
83
159
  return {
84
- flows
160
+ flows: [
161
+ ...headerFlows,
162
+ ...htmlFlows
163
+ ]
85
164
  };
86
165
  }
87
166
 
@@ -95,7 +174,9 @@ function selectFlowsByFlowType(flows, preferredFlowTypes) {
95
174
  }
96
175
  // Annotate the CommonJS export names for ESM import in node:
97
176
  0 && (module.exports = {
177
+ detectFlowCandidate,
98
178
  detectFlows,
179
+ detectFlowsFromLinkHeader,
99
180
  resolveQRX,
100
181
  selectFlowsByFlowType
101
182
  });
package/dist/index.d.cts CHANGED
@@ -1,4 +1,4 @@
1
- type FlowType = "feed";
1
+ type FlowType = "feed" | "qrx";
2
2
  interface Flow {
3
3
  flowType: FlowType;
4
4
  rel: string;
@@ -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
@@ -1,4 +1,4 @@
1
- type FlowType = "feed";
1
+ type FlowType = "feed" | "qrx";
2
2
  interface Flow {
3
3
  flowType: FlowType;
4
4
  rel: string;
@@ -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,32 +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
- if (!hasRel(normalizedRel, "alternate")) {
21
- 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;
22
94
  }
23
- if (!FEED_TYPES.has(normalizedType)) {
24
- continue;
95
+ if (name === "type") {
96
+ candidate.type = paramValue;
25
97
  }
26
- flows.push({
27
- flowType: "feed",
28
- rel: normalizedRel,
29
- href: new URL(href, sourceUrl).toString(),
30
- type: normalizedType
31
- });
32
98
  }
33
- return flows;
99
+ return candidate;
100
+ }
101
+ function splitLinkHeader(linkHeader) {
102
+ return linkHeader.split(/,\s*(?=<)/).map((part) => part.trim()).filter(Boolean);
34
103
  }
35
104
  function getAttribute(tag, attribute) {
36
105
  const regex = new RegExp(
@@ -47,13 +116,21 @@ function hasRel(rel, expected) {
47
116
  // src/resolve.ts
48
117
  async function resolveQRX(url) {
49
118
  const response = await fetch(url);
119
+ const sourceUrl = response.url;
120
+ const headerFlows = detectFlowsFromLinkHeader(
121
+ response.headers.get("link"),
122
+ sourceUrl
123
+ );
50
124
  const html = await response.text();
51
- const flows = detectFlows(
125
+ const htmlFlows = detectFlows(
52
126
  html,
53
- response.url
127
+ sourceUrl
54
128
  );
55
129
  return {
56
- flows
130
+ flows: [
131
+ ...headerFlows,
132
+ ...htmlFlows
133
+ ]
57
134
  };
58
135
  }
59
136
 
@@ -66,7 +143,9 @@ function selectFlowsByFlowType(flows, preferredFlowTypes) {
66
143
  );
67
144
  }
68
145
  export {
146
+ detectFlowCandidate,
69
147
  detectFlows,
148
+ detectFlowsFromLinkHeader,
70
149
  resolveQRX,
71
150
  selectFlowsByFlowType
72
151
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qrxcode/js",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "QRX flow discovery SDK for JavaScript.",
5
5
  "homepage": "https://qrx.dev",
6
6
  "repository": {