@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 +124 -32
- package/dist/index.cjs +105 -24
- package/dist/index.d.cts +9 -2
- package/dist/index.d.ts +9 -2
- package/dist/index.js +103 -24
- package/package.json +1 -1
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
|
|
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`
|
|
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://
|
|
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://
|
|
212
|
+
href: "https://example.com/feed.xml",
|
|
120
213
|
type: "application/rss+xml"
|
|
121
214
|
},
|
|
122
215
|
{
|
|
123
|
-
flowType: "
|
|
124
|
-
rel: "
|
|
125
|
-
href: "https://
|
|
126
|
-
type: "application/
|
|
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://
|
|
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
|
|
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
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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="
|
|
212
|
-
type="application/
|
|
213
|
-
href="/
|
|
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
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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 (
|
|
52
|
-
|
|
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
|
|
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
|
|
155
|
+
const htmlFlows = detectFlows(
|
|
80
156
|
html,
|
|
81
|
-
|
|
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
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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 (
|
|
24
|
-
|
|
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
|
|
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
|
|
125
|
+
const htmlFlows = detectFlows(
|
|
52
126
|
html,
|
|
53
|
-
|
|
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
|
};
|