openbriefing 0.1.1 → 0.3.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
@@ -2,17 +2,24 @@
2
2
 
3
3
  This package is the reference parser and checker for the [Open Briefing Standard (OBS)](https://openbriefingstandard.org). OBS is how a publisher says how their content should be summarized and presented when software consumes it.
4
4
 
5
- **Version 0.1.** It contains the parser and `openbriefing check`. Later versions add `generate`, `preview`, `backfill`, `review` and a Ghost integration.
5
+ **Version 0.3.** It contains the parser and `openbriefing check`, and reads spec version 1 (release candidate). One tag, `obs:summary`, is enough: `obs:modified` is optional, and the parser reports where a modified time came from. `check` reads a page, a feed or a whole sitemap from the web.
6
6
 
7
- ## Check a page or a feed
7
+ ## Check a page, a feed or a site
8
8
 
9
9
  ```bash
10
+ npx openbriefing check https://example.com/a-post/
10
11
  npx openbriefing check ./post.html
11
- npx openbriefing check ./feed.xml
12
- curl -s https://example.com/post/ | npx openbriefing check - --url https://example.com/post/
12
+ npx openbriefing check https://example.com/rss/ --feed --pages
13
+ npx openbriefing check https://example.com/sitemap.xml --sitemap
13
14
  ```
14
15
 
15
- `check` exits 1 when the tags are unusable, so a site's CI can gate on it. Add `--json` to get the parsed result as JSON.
16
+ - **A page:** prints each form with its word count, the keep lines, the highlights (and whether each is in the page's text), the modified time and its source, and a fingerprint.
17
+ - **`--feed`:** checks every item. With `--pages` it also fetches each item's page and says which of the two stands.
18
+ - **`--sitemap`:** one line per page (`ok`, `ok, 2 warnings`, `no tags`, `not usable`) and a total. `--limit 20` checks the first 20.
19
+
20
+ `check` exits 1 when tags are present but unusable, so a site's CI can gate on it. A page with no tags is not an error in a sitemap. Add `--json` for the parsed result. For a file with no canonical link, `--url` gives the page's address; `-` reads standard input.
21
+
22
+ Fetching is bounded: `http` and `https` only, 10 seconds, 5 MB, 5 redirects, and it names itself `openbriefing-check/<version>`.
16
23
 
17
24
  ## Use the parser
18
25
 
@@ -22,6 +29,8 @@ import { parseObs } from "openbriefing";
22
29
 
23
30
  const dom = new JSDOM(html, { url });
24
31
  const { brief, errors, warnings } = parseObs(dom.window.document, url);
32
+ // brief.summaries, brief.keep, brief.highlights (each { text, words, label?, inContent? }),
33
+ // brief.modified (optional) and brief.modifiedFrom ("obs" | "article" | "feed" | "none"), …
25
34
  ```
26
35
 
27
36
  | Export | What it does |
@@ -29,10 +38,11 @@ const { brief, errors, warnings } = parseObs(dom.window.document, url);
29
38
  | `parseObs(document, pageUrl)` | Reads a page's `obs:` meta tags |
30
39
  | `parseFeedObs(itemElement, itemUrl?)` | Reads one RSS `<item>` or Atom `<entry>` |
31
40
  | `validateObs(object)` | Checks an object already in the `ObsBrief` shape |
32
- | `mergeObs(page, feed)` | Applies the feed-against-page rule: the later `obs:modified` stands, and the page wins a tie |
33
- | `tierFor(words)`, `SIZES`, `normalizeUrl` | Helpers that use the spec's numbers |
41
+ | `mergeObs(page, feed)` | Applies the feed-against-page rule: the later effective modified time stands; the page wins a tie, and when either has no time |
42
+ | `fingerprint(brief)` | A stable 16-character hash of every text value and setting. Compare two to tell whether a brief changed, with or without a modified time |
43
+ | `tierFor(words)`, `SIZES`, `normalizeUrl`, `looseText` | Helpers that use the spec's numbers and its loose text comparison |
34
44
 
35
- The parser has no runtime dependencies. It takes any DOM `Document` (jsdom, linkedom, or a browser's `DOMParser`), makes no network requests, and never throws.
45
+ The parser has no runtime dependencies. It takes any DOM `Document` (jsdom, linkedom, or a browser's `DOMParser`), makes no network requests, and never throws. The bounded fetch that `check` uses is exported separately as `openbriefing/fetch`.
36
46
 
37
47
  The test fixtures ship with the package in `openbriefing/fixtures/`, with `expected.json`.
38
48
 
package/dist/cli.js CHANGED
@@ -1,52 +1,83 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * openbriefing check <file | -> [--url <page url>] [--json]
3
+ * openbriefing check <url | file | -> [--url <page url>] [--json]
4
+ * openbriefing check <feed url | file> --feed [--pages]
5
+ * openbriefing check <sitemap url | file> --sitemap [--limit <n>]
4
6
  *
5
- * Checks a local HTML page or an RSS/Atom feed for Open Briefing Standard
6
- * tags and prints what a reader sees. Exits 1 when the tags are unusable.
7
- * Version 0.1 reads local files and standard input only; it makes no
8
- * network requests.
7
+ * Checks a page, a feed or a whole sitemap for Open Briefing Standard tags
8
+ * and prints what a reader sees. Exits 1 when tags are present but unusable.
9
9
  */
10
10
  import { readFileSync } from "node:fs";
11
11
  import { basename, resolve } from "node:path";
12
12
  import { pathToFileURL } from "node:url";
13
13
  import { JSDOM } from "jsdom";
14
- import { countWords, parseFeedObs, parseObs, SIZES } from "./obs.js";
15
- const USAGE = `Usage: openbriefing check <file | -> [--url <page url>] [--json]
14
+ import { DEFAULT_LIMITS, fetchText, isHttpUrl, mapLimit, userAgent } from "./fetch.js";
15
+ import { countWords, fingerprint, mergeObs, parseFeedObs, parseObs, SIZES } from "./obs.js";
16
+ const VERSION = (() => {
17
+ try {
18
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
19
+ }
20
+ catch {
21
+ return "0";
22
+ }
23
+ })();
24
+ const LIMITS = { ...DEFAULT_LIMITS, userAgent: userAgent(VERSION) };
25
+ const USAGE = `Usage:
26
+ openbriefing check <url | file | -> [--url <page url>] [--json]
27
+ openbriefing check <feed url | file> --feed [--pages]
28
+ openbriefing check <sitemap url | file> --sitemap [--limit <n>]
16
29
 
17
- Checks a page or a feed for Open Briefing Standard tags.
18
- --url the page's address, used when the page has no canonical link
19
- --json print the parsed result as JSON
30
+ Checks a page, a feed or a sitemap for Open Briefing Standard tags.
31
+ --url the page's address, when checking a file with no canonical link
32
+ --feed treat the input as an RSS or Atom feed (detected when omitted)
33
+ --pages with a feed: also fetch each item's page and say which one stands
34
+ --sitemap check every page a sitemap lists, one line each
35
+ --limit with a sitemap: check only the first <n> pages
36
+ --json print the parsed result as JSON
20
37
  Spec: https://openbriefingstandard.org`;
21
38
  function parseArgs(argv) {
22
- const args = { json: false };
39
+ const args = { json: false, feed: false, pages: false, sitemap: false };
23
40
  for (let i = 0; i < argv.length; i++) {
24
41
  const a = argv[i];
25
42
  if (a === "--json")
26
43
  args.json = true;
27
- else if (a === "--url")
28
- args.url = argv[++i];
44
+ else if (a === "--feed")
45
+ args.feed = true;
46
+ else if (a === "--pages")
47
+ args.pages = true;
48
+ else if (a === "--sitemap")
49
+ args.sitemap = true;
50
+ else if (a === "--url") {
51
+ const v = argv[++i];
52
+ if (v === undefined)
53
+ args.bad = "--url needs an address";
54
+ else
55
+ args.url = v;
56
+ }
57
+ else if (a === "--limit") {
58
+ const n = Number.parseInt(argv[++i] ?? "", 10);
59
+ if (!Number.isInteger(n) || n < 1)
60
+ args.bad = "--limit needs a whole number above 0";
61
+ else
62
+ args.limit = n;
63
+ }
29
64
  else if (a === "-h" || a === "--help")
30
65
  args.command = "help";
66
+ else if (a === "-v" || a === "--version")
67
+ args.command = "version";
68
+ else if (a.startsWith("--"))
69
+ args.bad = `unknown option ${a}`;
31
70
  else if (args.command === undefined)
32
71
  args.command = a;
33
- else if (args.file === undefined)
34
- args.file = a;
72
+ else if (args.target === undefined)
73
+ args.target = a;
35
74
  }
36
75
  return args;
37
76
  }
38
- function readInput(file) {
39
- return file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8");
40
- }
41
- function isFeed(text, file) {
42
- if (/\.(xml|rss|atom)$/iu.test(file))
43
- return true;
44
- const head = text.slice(0, 500).trimStart();
45
- return head.startsWith("<?xml") ? /<(rss|feed)\b/iu.test(text.slice(0, 2000)) : /^<(rss|feed)\b/iu.test(head);
46
- }
47
77
  /** Page text is untrusted: show control characters (ANSI escapes and the like) instead of sending them to the terminal. */
48
78
  const safe = (s) => s.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
49
79
  const cut = (s, n = 60) => safe((s.length > n ? `${s.slice(0, n - 1)}…` : s).replace(/\n/gu, " "));
80
+ const MODIFIED_FROM = { obs: "obs:modified", article: "article:modified_time", feed: "the feed item's date", none: "" };
50
81
  function describe(label, r) {
51
82
  const lines = [safe(label)];
52
83
  const b = r.brief;
@@ -61,13 +92,22 @@ function describe(label, r) {
61
92
  row(`summary`, `${s.tier.padEnd(6)} ${String(s.words).padStart(5)} words (target ${lo}–${hi}) ${s.from}`);
62
93
  }
63
94
  b.keep.forEach((k, i) => row(i === 0 ? "keep (core)" : `keep ${i + 1}`, cut(k)));
95
+ b.highlights.forEach((h, i) => {
96
+ const notes = [`${h.words}/${SIZES.highlight} words`];
97
+ if (h.label !== undefined)
98
+ notes.push(cut(h.label, 30));
99
+ if (h.inContent === false)
100
+ notes.push("not in the text");
101
+ row(`highlight ${i + 1}`, `${cut(h.text)} (${notes.join(", ")})`);
102
+ });
64
103
  row("derive", `${b.derive.mode}; translate ${b.derive.translate ? "yes" : "no"}; combine ${b.derive.combine ? "yes" : "no"}`);
65
104
  if (b.attribution !== undefined)
66
105
  row("attribution", cut(b.attribution));
67
106
  row("preference", b.preference);
68
107
  if (b.lang !== undefined)
69
108
  row("lang", safe(b.lang));
70
- row("modified", safe(b.modified));
109
+ row("modified", b.modified === undefined ? "none; readers compare the text of the tags" : `${safe(b.modified)} (from ${MODIFIED_FROM[b.modifiedFrom]})`);
110
+ row("fingerprint", fingerprint(b));
71
111
  if (b.url !== undefined)
72
112
  row("url", `${safe(b.url)}${b.describesThisPage ? "" : " (describes another page)"}`);
73
113
  }
@@ -78,46 +118,205 @@ function describe(label, r) {
78
118
  lines.push(b ? ` ok: usable, ${r.warnings.length} warning${r.warnings.length === 1 ? "" : "s"}` : " not usable");
79
119
  return lines.join("\n");
80
120
  }
81
- function check(args) {
82
- if (!args.file) {
83
- console.error(USAGE);
84
- return 2;
121
+ async function load(target, urlOverride) {
122
+ if (isHttpUrl(target)) {
123
+ const res = await fetchText(target, LIMITS);
124
+ if (!res.ok)
125
+ return { error: `Cannot fetch ${safe(target)}: ${safe(res.error)}` };
126
+ return { text: res.text, url: urlOverride ?? res.url, label: res.url, contentType: res.contentType };
85
127
  }
86
- let text;
87
128
  try {
88
- text = readInput(args.file);
129
+ const text = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
130
+ const url = urlOverride ?? (target === "-" ? "https://localhost/" : pathToFileURL(resolve(target)).href);
131
+ return { text, url, label: target === "-" ? "(standard input)" : basename(target), contentType: "" };
89
132
  }
90
133
  catch (err) {
91
- console.error(`Cannot read ${args.file}: ${err instanceof Error ? err.message : String(err)}`);
92
- return 2;
134
+ return { error: `Cannot read ${safe(target)}: ${err instanceof Error ? err.message : String(err)}` };
135
+ }
136
+ }
137
+ function looksLikeFeed(text, name, contentType) {
138
+ if (/\b(rss|atom)\+xml\b/iu.test(contentType))
139
+ return true;
140
+ if (/\.(rss|atom)$/iu.test(name))
141
+ return true;
142
+ const head = text.slice(0, 2000).trimStart();
143
+ return /^(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<\?[^>]*\?>\s*)*<(rss|feed)\b/iu.test(head);
144
+ }
145
+ const parsePage = (text, url) => parseObs(new JSDOM(text, { url }).window.document, url);
146
+ function feedItems(text) {
147
+ const doc = new JSDOM(text, { contentType: "text/xml" }).window.document;
148
+ return [...Array.from(doc.getElementsByTagName("item")), ...Array.from(doc.getElementsByTagName("entry"))];
149
+ }
150
+ function itemLink(item) {
151
+ for (const el of Array.from(item.children)) {
152
+ if (el.localName !== "link" || el.namespaceURI === "https://openbriefingstandard.org/ns#")
153
+ continue;
154
+ const href = el.getAttribute("href");
155
+ if (href !== null) {
156
+ const rel = el.getAttribute("rel");
157
+ if (rel === null || rel === "alternate")
158
+ return href.trim();
159
+ }
160
+ else if (el.textContent?.trim())
161
+ return el.textContent.trim();
162
+ }
163
+ return undefined;
164
+ }
165
+ const NO_PAGE_TAGS = "No obs: tags on the page";
166
+ const NO_ITEM_TAGS = "No OBS elements on the item";
167
+ const hasNoTags = (r) => r.brief === undefined && (r.errors[0] === NO_PAGE_TAGS || r.errors[0] === NO_ITEM_TAGS);
168
+ async function checkFeed(args, loaded) {
169
+ const items = feedItems(loaded.text);
170
+ if (items.length === 0) {
171
+ if (args.json)
172
+ console.log("[]");
173
+ else
174
+ console.log(`${safe(loaded.label)}\n not usable: no <item> or <entry> elements`);
175
+ return 1;
93
176
  }
94
- const pageUrl = args.url ?? (args.file === "-" ? "https://localhost/" : pathToFileURL(resolve(args.file)).href);
95
- const label = args.file === "-" ? "(standard input)" : basename(args.file);
96
- if (isFeed(text, args.file)) {
97
- const doc = new JSDOM(text, { contentType: "text/xml" }).window.document;
98
- const items = [...Array.from(doc.getElementsByTagName("item")), ...Array.from(doc.getElementsByTagName("entry"))];
99
- const results = items.map((item) => parseFeedObs(item));
177
+ const results = items.map((item) => parseFeedObs(item));
178
+ if (!args.pages) {
100
179
  if (args.json)
101
180
  console.log(JSON.stringify(results, null, 2));
102
181
  else
103
- results.forEach((r, i) => console.log(describe(`${label}, item ${i + 1}`, r)));
104
- if (items.length === 0) {
105
- if (!args.json)
106
- console.log(`${label}\n not usable: no <item> or <entry> elements`);
107
- return 1;
182
+ results.forEach((r, i) => console.log(describe(`${loaded.label}, item ${i + 1}`, r)));
183
+ return results.some((r) => r.brief === undefined && !hasNoTags(r)) || results.every((r) => r.brief === undefined) ? 1 : 0;
184
+ }
185
+ // --pages: fetch each item's page and apply the feed-against-page rule (§14).
186
+ const rows = await mapLimit(items, 4, async (item, i) => {
187
+ const feed = results[i];
188
+ const link = itemLink(item);
189
+ if (link === undefined || !isHttpUrl(link))
190
+ return { link, feed, note: "the item has no link to fetch" };
191
+ const res = await fetchText(link, LIMITS);
192
+ if (!res.ok)
193
+ return { link, feed, note: `page not fetched: ${res.error}` };
194
+ const page = parsePage(res.text, res.url);
195
+ const stands = mergeObs(page.brief, feed.brief);
196
+ const which = stands === undefined ? "neither" : stands === page.brief ? "page" : "feed";
197
+ const same = page.brief && feed.brief ? fingerprint(page.brief) === fingerprint(feed.brief) : undefined;
198
+ return { link, feed, page, stands: which, same };
199
+ });
200
+ if (args.json)
201
+ console.log(JSON.stringify(rows, null, 2));
202
+ else {
203
+ rows.forEach((row, i) => {
204
+ console.log(describe(`${loaded.label}, item ${i + 1}${row.link ? ` ${row.link}` : ""}`, row.feed));
205
+ if ("note" in row)
206
+ console.log(` page ${safe(row.note ?? "")}`);
207
+ else {
208
+ const p = row.page;
209
+ const state = p.brief ? `usable, ${p.warnings.length} warning${p.warnings.length === 1 ? "" : "s"}` : hasNoTags(p) ? "no tags" : "not usable";
210
+ const agree = row.same === undefined ? "" : row.same ? "; page and feed say the same" : "; page and feed differ";
211
+ console.log(` page ${state}${agree}; the ${row.stands} stands`);
212
+ if (!p.brief && !hasNoTags(p))
213
+ for (const e of p.errors)
214
+ console.log(` page error ${safe(e)}`);
215
+ }
216
+ });
217
+ }
218
+ const broken = rows.some((row) => (row.feed.brief === undefined && !hasNoTags(row.feed)) || ("page" in row && row.page !== undefined && row.page.brief === undefined && !hasNoTags(row.page)));
219
+ return broken ? 1 : 0;
220
+ }
221
+ const unescapeXml = (s) => s.replace(/&lt;/gu, "<").replace(/&gt;/gu, ">").replace(/&quot;/gu, '"').replace(/&apos;/gu, "'").replace(/&amp;/gu, "&");
222
+ function locs(xml) {
223
+ return [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?\s*([^<\]]+?)\s*(?:\]\]>)?\s*<\/loc>/giu)].map((m) => unescapeXml(m[1]));
224
+ }
225
+ /** Image and video sitemaps nest their own <loc> elements; only a <url>'s direct one is a page. */
226
+ function pageLocs(xml) {
227
+ const out = [];
228
+ for (const m of xml.matchAll(/<url\b[^>]*>([\s\S]*?)<\/url>/giu)) {
229
+ const inner = m[1].replace(/<(image|video|news):[a-z]+\b[\s\S]*?<\/\1:[a-z]+>/giu, "");
230
+ const first = locs(inner)[0];
231
+ if (first !== undefined)
232
+ out.push(first);
233
+ }
234
+ return out;
235
+ }
236
+ async function checkSitemap(args, loaded) {
237
+ let pages;
238
+ if (/<sitemapindex\b/iu.test(loaded.text)) {
239
+ const children = locs(loaded.text).filter(isHttpUrl).slice(0, 50);
240
+ const sets = await mapLimit(children, 4, async (child) => {
241
+ const res = await fetchText(child, LIMITS);
242
+ return res.ok ? pageLocs(res.text) : [];
243
+ });
244
+ pages = sets.flat();
245
+ }
246
+ else {
247
+ pages = pageLocs(loaded.text);
248
+ }
249
+ pages = [...new Set(pages.filter(isHttpUrl))];
250
+ const total = pages.length;
251
+ if (args.limit !== undefined)
252
+ pages = pages.slice(0, args.limit);
253
+ if (pages.length === 0) {
254
+ if (args.json)
255
+ console.log("[]");
256
+ else
257
+ console.log(`${safe(loaded.label)}\n no page addresses found`);
258
+ return 1;
259
+ }
260
+ const rows = await mapLimit(pages, 4, async (url) => {
261
+ const res = await fetchText(url, LIMITS);
262
+ if (!res.ok)
263
+ return { url, status: "not fetched", warnings: 0, detail: res.error };
264
+ const result = parsePage(res.text, res.url);
265
+ if (result.brief)
266
+ return { url, status: "ok", warnings: result.warnings.length, result };
267
+ if (hasNoTags(result))
268
+ return { url, status: "no tags", warnings: 0, result };
269
+ return { url, status: "not usable", warnings: result.warnings.length, detail: result.errors.join("; "), result };
270
+ });
271
+ const count = (s) => rows.filter((r) => r.status === s).length;
272
+ if (args.json)
273
+ console.log(JSON.stringify(rows, null, 2));
274
+ else {
275
+ for (const r of rows) {
276
+ const state = r.status === "ok" && r.warnings > 0 ? `ok, ${r.warnings} warning${r.warnings === 1 ? "" : "s"}` : r.status;
277
+ console.log(`${state.padEnd(16)} ${safe(r.url)}${r.detail && r.status !== "ok" ? ` (${safe(r.detail)})` : ""}`);
108
278
  }
109
- return results.every((r) => r.brief) ? 0 : 1;
279
+ const parts = [`${rows.length} page${rows.length === 1 ? "" : "s"}`, `${count("ok")} ok`, `${count("no tags")} with no tags`, `${count("not usable")} not usable`];
280
+ if (count("not fetched") > 0)
281
+ parts.push(`${count("not fetched")} not fetched`);
282
+ console.log(`\n${parts.join(", ")}${rows.length < total ? ` (first ${rows.length} of ${total})` : ""}`);
283
+ }
284
+ return count("not usable") > 0 ? 1 : 0;
285
+ }
286
+ async function check(args) {
287
+ if (!args.target || args.bad) {
288
+ if (args.bad)
289
+ console.error(`${args.bad}\n`);
290
+ console.error(USAGE);
291
+ return 2;
110
292
  }
111
- const doc = new JSDOM(text, { url: pageUrl }).window.document;
112
- const r = parseObs(doc, pageUrl);
113
- console.log(args.json ? JSON.stringify(r, null, 2) : describe(label, r));
293
+ const loaded = await load(args.target, args.url);
294
+ if ("error" in loaded) {
295
+ console.error(loaded.error);
296
+ return 2;
297
+ }
298
+ if (args.sitemap)
299
+ return checkSitemap(args, loaded);
300
+ if (args.feed || looksLikeFeed(loaded.text, args.target, loaded.contentType))
301
+ return checkFeed(args, loaded);
302
+ const r = parsePage(loaded.text, loaded.url);
303
+ console.log(args.json ? JSON.stringify(r, null, 2) : describe(loaded.label, r));
114
304
  return r.brief ? 0 : 1;
115
305
  }
116
- function main(argv) {
306
+ async function main(argv) {
117
307
  const args = parseArgs(argv);
118
308
  if (args.command === "check")
119
309
  return check(args);
310
+ if (args.command === "version") {
311
+ console.log(VERSION);
312
+ return 0;
313
+ }
120
314
  console.error(USAGE);
121
315
  return args.command === "help" ? 0 : 2;
122
316
  }
123
- process.exitCode = main(process.argv.slice(2));
317
+ main(process.argv.slice(2)).then((code) => {
318
+ process.exitCode = code;
319
+ }, (err) => {
320
+ console.error(err instanceof Error ? err.message : String(err));
321
+ process.exitCode = 2;
322
+ });
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The network code for `openbriefing check`. Kept apart from obs.ts so the
3
+ * parser stays usable in a browser, with no platform API.
4
+ *
5
+ * Everything fetched is untrusted. The limits here bound time, size and
6
+ * redirects; callers escape control characters before printing.
7
+ */
8
+ export interface FetchLimits {
9
+ /** Whole-request budget in milliseconds, redirects included. */
10
+ timeoutMs: number;
11
+ /** The body is cut off, and the fetch fails, past this many bytes. */
12
+ maxBytes: number;
13
+ maxRedirects: number;
14
+ userAgent: string;
15
+ }
16
+ export declare const DEFAULT_LIMITS: Omit<FetchLimits, "userAgent">;
17
+ export type FetchResult = {
18
+ ok: true;
19
+ url: string;
20
+ contentType: string;
21
+ text: string;
22
+ } | {
23
+ ok: false;
24
+ url: string;
25
+ error: string;
26
+ };
27
+ export declare function userAgent(version: string): string;
28
+ /** True for an absolute http or https URL. */
29
+ export declare function isHttpUrl(input: string): boolean;
30
+ /** Fetches a page's text within the limits. Never throws. */
31
+ export declare function fetchText(input: string, limits: FetchLimits): Promise<FetchResult>;
32
+ /** Runs `fn` over `items`, at most `width` at a time, keeping the order of results. */
33
+ export declare function mapLimit<T, R>(items: readonly T[], width: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
package/dist/fetch.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The network code for `openbriefing check`. Kept apart from obs.ts so the
3
+ * parser stays usable in a browser, with no platform API.
4
+ *
5
+ * Everything fetched is untrusted. The limits here bound time, size and
6
+ * redirects; callers escape control characters before printing.
7
+ */
8
+ export const DEFAULT_LIMITS = {
9
+ timeoutMs: 10_000,
10
+ maxBytes: 5 * 1024 * 1024,
11
+ maxRedirects: 5,
12
+ };
13
+ export function userAgent(version) {
14
+ return `openbriefing-check/${version} (+https://openbriefingstandard.org)`;
15
+ }
16
+ /** True for an absolute http or https URL. */
17
+ export function isHttpUrl(input) {
18
+ try {
19
+ const u = new URL(input);
20
+ return u.protocol === "http:" || u.protocol === "https:";
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ /** Fetches a page's text within the limits. Never throws. */
27
+ export async function fetchText(input, limits) {
28
+ if (!isHttpUrl(input))
29
+ return { ok: false, url: input, error: "only http and https addresses are fetched" };
30
+ const controller = new AbortController();
31
+ const timer = setTimeout(() => controller.abort(), limits.timeoutMs);
32
+ let url = input;
33
+ try {
34
+ for (let hop = 0;; hop++) {
35
+ const res = await fetch(url, {
36
+ redirect: "manual",
37
+ signal: controller.signal,
38
+ headers: { "user-agent": limits.userAgent, accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.5" },
39
+ });
40
+ if (res.status >= 300 && res.status < 400) {
41
+ const location = res.headers.get("location");
42
+ await res.body?.cancel();
43
+ if (!location)
44
+ return { ok: false, url, error: `HTTP ${res.status} with no Location` };
45
+ if (hop >= limits.maxRedirects)
46
+ return { ok: false, url, error: `more than ${limits.maxRedirects} redirects` };
47
+ const next = new URL(location, url).href;
48
+ if (!isHttpUrl(next))
49
+ return { ok: false, url, error: "redirected to an address that is not http or https" };
50
+ url = next;
51
+ continue;
52
+ }
53
+ if (!res.ok) {
54
+ await res.body?.cancel();
55
+ return { ok: false, url, error: `HTTP ${res.status}` };
56
+ }
57
+ const declared = Number(res.headers.get("content-length") ?? "");
58
+ if (Number.isFinite(declared) && declared > limits.maxBytes) {
59
+ await res.body?.cancel();
60
+ return { ok: false, url, error: `larger than ${limits.maxBytes} bytes` };
61
+ }
62
+ const contentType = res.headers.get("content-type") ?? "";
63
+ const text = await readCapped(res, limits.maxBytes);
64
+ if (text === undefined)
65
+ return { ok: false, url, error: `larger than ${limits.maxBytes} bytes` };
66
+ return { ok: true, url, contentType, text };
67
+ }
68
+ }
69
+ catch (err) {
70
+ const aborted = controller.signal.aborted;
71
+ return { ok: false, url, error: aborted ? `no answer within ${limits.timeoutMs / 1000} s` : err instanceof Error ? cause(err) : String(err) };
72
+ }
73
+ finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
77
+ function cause(err) {
78
+ const c = err.cause;
79
+ if (c instanceof Error && c.message)
80
+ return c.message;
81
+ return err.message;
82
+ }
83
+ async function readCapped(res, maxBytes) {
84
+ if (!res.body)
85
+ return "";
86
+ const reader = res.body.getReader();
87
+ const chunks = [];
88
+ let total = 0;
89
+ for (;;) {
90
+ const { done, value } = await reader.read();
91
+ if (done)
92
+ break;
93
+ total += value.byteLength;
94
+ if (total > maxBytes) {
95
+ await reader.cancel();
96
+ return undefined;
97
+ }
98
+ chunks.push(value);
99
+ }
100
+ const all = new Uint8Array(total);
101
+ let at = 0;
102
+ for (const c of chunks) {
103
+ all.set(c, at);
104
+ at += c.byteLength;
105
+ }
106
+ return new TextDecoder("utf-8").decode(all);
107
+ }
108
+ /** Runs `fn` over `items`, at most `width` at a time, keeping the order of results. */
109
+ export async function mapLimit(items, width, fn) {
110
+ const out = new Array(items.length);
111
+ let next = 0;
112
+ const worker = async () => {
113
+ for (;;) {
114
+ const i = next++;
115
+ if (i >= items.length)
116
+ return;
117
+ out[i] = await fn(items[i], i);
118
+ }
119
+ };
120
+ await Promise.all(Array.from({ length: Math.min(width, items.length) }, worker));
121
+ return out;
122
+ }
package/dist/obs.d.ts CHANGED
@@ -11,6 +11,8 @@ export declare const OBS_NS = "https://openbriefingstandard.org/ns#";
11
11
  export type ObsTier = "short" | "medium" | "long";
12
12
  export type DeriveMode = "verbatim" | "anchor" | "open";
13
13
  export type Preference = "summary" | "full";
14
+ /** Where a brief's modified time came from (§13). */
15
+ export type ModifiedFrom = "obs" | "article" | "feed" | "none";
14
16
  export interface ObsSummary {
15
17
  tier: ObsTier;
16
18
  text: string;
@@ -18,10 +20,21 @@ export interface ObsSummary {
18
20
  /** "tiered" for obs:summary:{tier}; "inferred" for a bare obs:summary (§4). */
19
21
  from: "tiered" | "inferred";
20
22
  }
23
+ export interface ObsHighlight {
24
+ /** The passage, as written. */
25
+ text: string;
26
+ words: number;
27
+ /** obs:highlight:label, 6 words or fewer, when given. */
28
+ label?: string;
29
+ /** False when the page's text does not contain the highlight (§7). Absent for feed items and empty pages. */
30
+ inContent?: boolean;
31
+ }
21
32
  export interface ObsBrief {
22
33
  version: number;
23
- /** obs:modified, as given (ISO 8601). */
24
- modified: string;
34
+ /** The effective modified time, ISO 8601 (§13). Absent when no source gave one. */
35
+ modified?: string;
36
+ /** Where `modified` came from: obs:modified, article:modified_time, the feed item's date, or nowhere. */
37
+ modifiedFrom: ModifiedFrom;
25
38
  /** obs:url, normalized, when given. */
26
39
  url?: string;
27
40
  /** False when obs:url names content other than this page (§11). */
@@ -32,6 +45,8 @@ export interface ObsBrief {
32
45
  summaries: ObsSummary[];
33
46
  /** Priority order, at most 3. keep[0] is the core message (§6). */
34
47
  keep: string[];
48
+ /** Priority order, at most 5 (§7). Empty when none. */
49
+ highlights: ObsHighlight[];
35
50
  derive: {
36
51
  mode: DeriveMode;
37
52
  translate: boolean;
@@ -56,6 +71,9 @@ export declare const SIZES: {
56
71
  readonly keep: 40;
57
72
  readonly attribution: 12;
58
73
  readonly keepMax: 3;
74
+ readonly highlight: 60;
75
+ readonly highlightLabel: 6;
76
+ readonly highlightMax: 5;
59
77
  readonly tiers: Record<ObsTier, {
60
78
  target: [number, number];
61
79
  }>;
@@ -68,8 +86,13 @@ export declare const SIZES: {
68
86
  export declare function tierFor(words: number): ObsTier;
69
87
  /** Words are runs of characters between white space (§4). */
70
88
  export declare function countWords(text: string): number;
71
- /** URL comparison of §11: scheme and host case, trailing slash, tracking parameters. */
89
+ /** URL comparison of §12: scheme and host case, trailing slash, tracking parameters. */
72
90
  export declare function normalizeUrl(input: string, base?: string): string | undefined;
91
+ /**
92
+ * The loose comparison of §7: compatibility forms, straight quotes for curly,
93
+ * one kind of dash, one kind of space, one case. Words themselves must match.
94
+ */
95
+ export declare function looseText(s: string): string;
73
96
  /**
74
97
  * Reads a page's obs: meta tags. `pageUrl` is where the page was fetched
75
98
  * from; the page's <link rel="canonical"> takes precedence for §11.
@@ -82,5 +105,19 @@ export declare function parseObs(doc: Document, pageUrl: string): ParseResult;
82
105
  export declare function parseFeedObs(item: Element, itemUrl?: string): ParseResult;
83
106
  /** Checks an object in the ObsBrief shape and rebuilds it under the same rules. */
84
107
  export declare function validateObs(input: unknown): ParseResult;
85
- /** The later obs:modified stands; on a tie, the page stands. */
108
+ /**
109
+ * The later effective modified time stands. When the two are equal, or either
110
+ * side has none, the page stands.
111
+ */
86
112
  export declare function mergeObs(page?: ObsBrief, feed?: ObsBrief): ObsBrief | undefined;
113
+ /**
114
+ * A stable hash of what a brief says: every text value and every setting, in a
115
+ * fixed order. Two briefs with the same fingerprint present the same. It leaves
116
+ * out the modified time, and everything that depends on where the tags were
117
+ * read rather than on what they say (the language fallback, describesThisPage,
118
+ * a highlight's inContent), so a page and its feed item agree. Readers compare
119
+ * fingerprints to tell a change, above all when there is no modified time.
120
+ *
121
+ * SHA-256 of the canonical JSON below, in hex, first 16 characters.
122
+ */
123
+ export declare function fingerprint(brief: ObsBrief): string;