openbriefing 0.2.0 → 0.4.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/dist/cli.js CHANGED
@@ -1,52 +1,115 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * openbriefing check <file | -> [--url <page url>] [--json]
3
+ * openbriefing tags <file.yaml | post.md | -> [--format html | feed | json] [--marked] [--allow-draft]
4
+ * openbriefing ghost pull <folder> [--slug <slug>]
5
+ * openbriefing ghost push <folder> [--slug <slug>] [--dry-run]
6
+ * openbriefing check <url | file | -> [--url <page url>] [--json]
7
+ * openbriefing check <feed url | file> --feed [--pages]
8
+ * openbriefing check <sitemap url | file> --sitemap [--limit <n>]
4
9
  *
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.
10
+ * Checks a page, a feed or a whole sitemap for Open Briefing Standard tags
11
+ * and prints what a reader sees. Exits 1 when tags are present but unusable.
9
12
  */
10
13
  import { readFileSync } from "node:fs";
11
14
  import { basename, resolve } from "node:path";
12
15
  import { pathToFileURL } from "node:url";
13
16
  import { JSDOM } from "jsdom";
14
- import { countWords, parseFeedObs, parseObs, SIZES } from "./obs.js";
15
- const USAGE = `Usage: openbriefing check <file | -> [--url <page url>] [--json]
17
+ import { markTags, parseBriefing, renderTags } from "./briefing.js";
18
+ import { DEFAULT_LIMITS, fetchText, isHttpUrl, mapLimit, userAgent } from "./fetch.js";
19
+ import { countWords, fingerprint, mergeObs, parseFeedObs, parseObs, SIZES } from "./obs.js";
20
+ const VERSION = (() => {
21
+ try {
22
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
23
+ }
24
+ catch {
25
+ return "0";
26
+ }
27
+ })();
28
+ const LIMITS = { ...DEFAULT_LIMITS, userAgent: userAgent(VERSION) };
29
+ const USAGE = `Usage:
30
+ openbriefing tags <file.yaml | post.md | -> [--format html | feed | json] [--marked] [--allow-draft]
31
+ openbriefing ghost pull <folder> [--slug <slug>]
32
+ openbriefing ghost push <folder> [--slug <slug>] [--dry-run]
33
+ openbriefing check <url | file | -> [--url <page url>] [--json]
34
+ openbriefing check <feed url | file> --feed [--pages]
35
+ openbriefing check <sitemap url | file> --sitemap [--limit <n>]
16
36
 
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
37
+ tags turns a briefing file into escaped tags. ghost pull writes one file per
38
+ published post; ghost push writes each approved file's tags into its post
39
+ (GHOST_URL and GHOST_ADMIN_API_KEY in the environment). check reads a page,
40
+ a feed or a sitemap and prints what a reader sees.
41
+ --format html (meta tags, the default), feed (RSS and Atom elements) or json
42
+ --marked wrap the tags in the obs:start and obs:end comments
43
+ --allow-draft write tags for a file whose status is not approved
44
+ --slug with ghost: one post only
45
+ --dry-run with ghost push: say what would be written, and write nothing
46
+ --url the page's address, when checking a file with no canonical link
47
+ --feed treat the input as an RSS or Atom feed (detected when omitted)
48
+ --pages with a feed: also fetch each item's page and say which one stands
49
+ --sitemap check every page a sitemap lists, one line each
50
+ --limit with a sitemap: check only the first <n> pages
51
+ --json print the parsed result as JSON
20
52
  Spec: https://openbriefingstandard.org`;
21
53
  function parseArgs(argv) {
22
- const args = { json: false };
54
+ const args = { json: false, feed: false, pages: false, sitemap: false, rest: [], marked: false, allowDraft: false, dryRun: false };
23
55
  for (let i = 0; i < argv.length; i++) {
24
56
  const a = argv[i];
25
57
  if (a === "--json")
26
58
  args.json = true;
27
- else if (a === "--url")
28
- args.url = argv[++i];
59
+ else if (a === "--feed")
60
+ args.feed = true;
61
+ else if (a === "--pages")
62
+ args.pages = true;
63
+ else if (a === "--sitemap")
64
+ args.sitemap = true;
65
+ else if (a === "--marked")
66
+ args.marked = true;
67
+ else if (a === "--allow-draft")
68
+ args.allowDraft = true;
69
+ else if (a === "--dry-run")
70
+ args.dryRun = true;
71
+ else if (a === "--format" || a === "--slug") {
72
+ const v = argv[++i];
73
+ if (v === undefined)
74
+ args.bad = `${a} needs a value`;
75
+ else if (a === "--format")
76
+ args.format = v;
77
+ else
78
+ args.slug = v;
79
+ }
80
+ else if (a === "--url") {
81
+ const v = argv[++i];
82
+ if (v === undefined)
83
+ args.bad = "--url needs an address";
84
+ else
85
+ args.url = v;
86
+ }
87
+ else if (a === "--limit") {
88
+ const n = Number.parseInt(argv[++i] ?? "", 10);
89
+ if (!Number.isInteger(n) || n < 1)
90
+ args.bad = "--limit needs a whole number above 0";
91
+ else
92
+ args.limit = n;
93
+ }
29
94
  else if (a === "-h" || a === "--help")
30
95
  args.command = "help";
96
+ else if (a === "-v" || a === "--version")
97
+ args.command = "version";
98
+ else if (a.startsWith("--"))
99
+ args.bad = `unknown option ${a}`;
31
100
  else if (args.command === undefined)
32
101
  args.command = a;
33
- else if (args.file === undefined)
34
- args.file = a;
102
+ else if (args.target === undefined)
103
+ args.target = a;
104
+ else
105
+ args.rest.push(a);
35
106
  }
36
107
  return args;
37
108
  }
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
109
  /** Page text is untrusted: show control characters (ANSI escapes and the like) instead of sending them to the terminal. */
48
110
  const safe = (s) => s.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
49
111
  const cut = (s, n = 60) => safe((s.length > n ? `${s.slice(0, n - 1)}…` : s).replace(/\n/gu, " "));
112
+ const MODIFIED_FROM = { obs: "obs:modified", article: "article:modified_time", feed: "the feed item's date", none: "" };
50
113
  function describe(label, r) {
51
114
  const lines = [safe(label)];
52
115
  const b = r.brief;
@@ -75,7 +138,8 @@ function describe(label, r) {
75
138
  row("preference", b.preference);
76
139
  if (b.lang !== undefined)
77
140
  row("lang", safe(b.lang));
78
- row("modified", safe(b.modified));
141
+ row("modified", b.modified === undefined ? "none; readers compare the text of the tags" : `${safe(b.modified)} (from ${MODIFIED_FROM[b.modifiedFrom]})`);
142
+ row("fingerprint", fingerprint(b));
79
143
  if (b.url !== undefined)
80
144
  row("url", `${safe(b.url)}${b.describesThisPage ? "" : " (describes another page)"}`);
81
145
  }
@@ -86,46 +150,277 @@ function describe(label, r) {
86
150
  lines.push(b ? ` ok: usable, ${r.warnings.length} warning${r.warnings.length === 1 ? "" : "s"}` : " not usable");
87
151
  return lines.join("\n");
88
152
  }
89
- function check(args) {
90
- if (!args.file) {
91
- console.error(USAGE);
92
- return 2;
153
+ async function load(target, urlOverride) {
154
+ if (isHttpUrl(target)) {
155
+ const res = await fetchText(target, LIMITS);
156
+ if (!res.ok)
157
+ return { error: `Cannot fetch ${safe(target)}: ${safe(res.error)}` };
158
+ return { text: res.text, url: urlOverride ?? res.url, label: res.url, contentType: res.contentType };
93
159
  }
94
- let text;
95
160
  try {
96
- text = readInput(args.file);
161
+ const text = target === "-" ? readFileSync(0, "utf8") : readFileSync(target, "utf8");
162
+ const url = urlOverride ?? (target === "-" ? "https://localhost/" : pathToFileURL(resolve(target)).href);
163
+ return { text, url, label: target === "-" ? "(standard input)" : basename(target), contentType: "" };
97
164
  }
98
165
  catch (err) {
99
- console.error(`Cannot read ${args.file}: ${err instanceof Error ? err.message : String(err)}`);
100
- return 2;
166
+ return { error: `Cannot read ${safe(target)}: ${err instanceof Error ? err.message : String(err)}` };
167
+ }
168
+ }
169
+ function looksLikeFeed(text, name, contentType) {
170
+ if (/\b(rss|atom)\+xml\b/iu.test(contentType))
171
+ return true;
172
+ if (/\.(rss|atom)$/iu.test(name))
173
+ return true;
174
+ const head = text.slice(0, 2000).trimStart();
175
+ return /^(?:<\?xml[^>]*\?>\s*)?(?:<!--[\s\S]*?-->\s*)*(?:<\?[^>]*\?>\s*)*<(rss|feed)\b/iu.test(head);
176
+ }
177
+ const parsePage = (text, url) => parseObs(new JSDOM(text, { url }).window.document, url);
178
+ function feedItems(text) {
179
+ const doc = new JSDOM(text, { contentType: "text/xml" }).window.document;
180
+ return [...Array.from(doc.getElementsByTagName("item")), ...Array.from(doc.getElementsByTagName("entry"))];
181
+ }
182
+ function itemLink(item) {
183
+ for (const el of Array.from(item.children)) {
184
+ if (el.localName !== "link" || el.namespaceURI === "https://openbriefingstandard.org/ns#")
185
+ continue;
186
+ const href = el.getAttribute("href");
187
+ if (href !== null) {
188
+ const rel = el.getAttribute("rel");
189
+ if (rel === null || rel === "alternate")
190
+ return href.trim();
191
+ }
192
+ else if (el.textContent?.trim())
193
+ return el.textContent.trim();
101
194
  }
102
- const pageUrl = args.url ?? (args.file === "-" ? "https://localhost/" : pathToFileURL(resolve(args.file)).href);
103
- const label = args.file === "-" ? "(standard input)" : basename(args.file);
104
- if (isFeed(text, args.file)) {
105
- const doc = new JSDOM(text, { contentType: "text/xml" }).window.document;
106
- const items = [...Array.from(doc.getElementsByTagName("item")), ...Array.from(doc.getElementsByTagName("entry"))];
107
- const results = items.map((item) => parseFeedObs(item));
195
+ return undefined;
196
+ }
197
+ const NO_PAGE_TAGS = "No obs: tags on the page";
198
+ const NO_ITEM_TAGS = "No OBS elements on the item";
199
+ const hasNoTags = (r) => r.brief === undefined && (r.errors[0] === NO_PAGE_TAGS || r.errors[0] === NO_ITEM_TAGS);
200
+ async function checkFeed(args, loaded) {
201
+ const items = feedItems(loaded.text);
202
+ if (items.length === 0) {
203
+ if (args.json)
204
+ console.log("[]");
205
+ else
206
+ console.log(`${safe(loaded.label)}\n not usable: no <item> or <entry> elements`);
207
+ return 1;
208
+ }
209
+ const results = items.map((item) => parseFeedObs(item));
210
+ if (!args.pages) {
108
211
  if (args.json)
109
212
  console.log(JSON.stringify(results, null, 2));
110
213
  else
111
- results.forEach((r, i) => console.log(describe(`${label}, item ${i + 1}`, r)));
112
- if (items.length === 0) {
113
- if (!args.json)
114
- console.log(`${label}\n not usable: no <item> or <entry> elements`);
115
- return 1;
214
+ results.forEach((r, i) => console.log(describe(`${loaded.label}, item ${i + 1}`, r)));
215
+ return results.some((r) => r.brief === undefined && !hasNoTags(r)) || results.every((r) => r.brief === undefined) ? 1 : 0;
216
+ }
217
+ // --pages: fetch each item's page and apply the feed-against-page rule (§14).
218
+ const rows = await mapLimit(items, 4, async (item, i) => {
219
+ const feed = results[i];
220
+ const link = itemLink(item);
221
+ if (link === undefined || !isHttpUrl(link))
222
+ return { link, feed, note: "the item has no link to fetch" };
223
+ const res = await fetchText(link, LIMITS);
224
+ if (!res.ok)
225
+ return { link, feed, note: `page not fetched: ${res.error}` };
226
+ const page = parsePage(res.text, res.url);
227
+ const stands = mergeObs(page.brief, feed.brief);
228
+ const which = stands === undefined ? "neither" : stands === page.brief ? "page" : "feed";
229
+ const same = page.brief && feed.brief ? fingerprint(page.brief) === fingerprint(feed.brief) : undefined;
230
+ return { link, feed, page, stands: which, same };
231
+ });
232
+ if (args.json)
233
+ console.log(JSON.stringify(rows, null, 2));
234
+ else {
235
+ rows.forEach((row, i) => {
236
+ console.log(describe(`${loaded.label}, item ${i + 1}${row.link ? ` ${row.link}` : ""}`, row.feed));
237
+ if ("note" in row)
238
+ console.log(` page ${safe(row.note ?? "")}`);
239
+ else {
240
+ const p = row.page;
241
+ const state = p.brief ? `usable, ${p.warnings.length} warning${p.warnings.length === 1 ? "" : "s"}` : hasNoTags(p) ? "no tags" : "not usable";
242
+ const agree = row.same === undefined ? "" : row.same ? "; page and feed say the same" : "; page and feed differ";
243
+ console.log(` page ${state}${agree}; the ${row.stands} stands`);
244
+ if (!p.brief && !hasNoTags(p))
245
+ for (const e of p.errors)
246
+ console.log(` page error ${safe(e)}`);
247
+ }
248
+ });
249
+ }
250
+ 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)));
251
+ return broken ? 1 : 0;
252
+ }
253
+ const unescapeXml = (s) => s.replace(/&lt;/gu, "<").replace(/&gt;/gu, ">").replace(/&quot;/gu, '"').replace(/&apos;/gu, "'").replace(/&amp;/gu, "&");
254
+ function locs(xml) {
255
+ return [...xml.matchAll(/<loc>\s*(?:<!\[CDATA\[)?\s*([^<\]]+?)\s*(?:\]\]>)?\s*<\/loc>/giu)].map((m) => unescapeXml(m[1]));
256
+ }
257
+ /** Image and video sitemaps nest their own <loc> elements; only a <url>'s direct one is a page. */
258
+ function pageLocs(xml) {
259
+ const out = [];
260
+ for (const m of xml.matchAll(/<url\b[^>]*>([\s\S]*?)<\/url>/giu)) {
261
+ const inner = m[1].replace(/<(image|video|news):[a-z]+\b[\s\S]*?<\/\1:[a-z]+>/giu, "");
262
+ const first = locs(inner)[0];
263
+ if (first !== undefined)
264
+ out.push(first);
265
+ }
266
+ return out;
267
+ }
268
+ async function checkSitemap(args, loaded) {
269
+ let pages;
270
+ if (/<sitemapindex\b/iu.test(loaded.text)) {
271
+ const children = locs(loaded.text).filter(isHttpUrl).slice(0, 50);
272
+ const sets = await mapLimit(children, 4, async (child) => {
273
+ const res = await fetchText(child, LIMITS);
274
+ return res.ok ? pageLocs(res.text) : [];
275
+ });
276
+ pages = sets.flat();
277
+ }
278
+ else {
279
+ pages = pageLocs(loaded.text);
280
+ }
281
+ pages = [...new Set(pages.filter(isHttpUrl))];
282
+ const total = pages.length;
283
+ if (args.limit !== undefined)
284
+ pages = pages.slice(0, args.limit);
285
+ if (pages.length === 0) {
286
+ if (args.json)
287
+ console.log("[]");
288
+ else
289
+ console.log(`${safe(loaded.label)}\n no page addresses found`);
290
+ return 1;
291
+ }
292
+ const rows = await mapLimit(pages, 4, async (url) => {
293
+ const res = await fetchText(url, LIMITS);
294
+ if (!res.ok)
295
+ return { url, status: "not fetched", warnings: 0, detail: res.error };
296
+ const result = parsePage(res.text, res.url);
297
+ if (result.brief)
298
+ return { url, status: "ok", warnings: result.warnings.length, result };
299
+ if (hasNoTags(result))
300
+ return { url, status: "no tags", warnings: 0, result };
301
+ return { url, status: "not usable", warnings: result.warnings.length, detail: result.errors.join("; "), result };
302
+ });
303
+ const count = (s) => rows.filter((r) => r.status === s).length;
304
+ if (args.json)
305
+ console.log(JSON.stringify(rows, null, 2));
306
+ else {
307
+ for (const r of rows) {
308
+ const state = r.status === "ok" && r.warnings > 0 ? `ok, ${r.warnings} warning${r.warnings === 1 ? "" : "s"}` : r.status;
309
+ console.log(`${state.padEnd(16)} ${safe(r.url)}${r.detail && r.status !== "ok" ? ` (${safe(r.detail)})` : ""}`);
116
310
  }
117
- return results.every((r) => r.brief) ? 0 : 1;
311
+ const parts = [`${rows.length} page${rows.length === 1 ? "" : "s"}`, `${count("ok")} ok`, `${count("no tags")} with no tags`, `${count("not usable")} not usable`];
312
+ if (count("not fetched") > 0)
313
+ parts.push(`${count("not fetched")} not fetched`);
314
+ console.log(`\n${parts.join(", ")}${rows.length < total ? ` (first ${rows.length} of ${total})` : ""}`);
118
315
  }
119
- const doc = new JSDOM(text, { url: pageUrl }).window.document;
120
- const r = parseObs(doc, pageUrl);
121
- console.log(args.json ? JSON.stringify(r, null, 2) : describe(label, r));
316
+ return count("not usable") > 0 ? 1 : 0;
317
+ }
318
+ async function check(args) {
319
+ if (!args.target || args.bad) {
320
+ if (args.bad)
321
+ console.error(`${args.bad}\n`);
322
+ console.error(USAGE);
323
+ return 2;
324
+ }
325
+ const loaded = await load(args.target, args.url);
326
+ if ("error" in loaded) {
327
+ console.error(loaded.error);
328
+ return 2;
329
+ }
330
+ if (args.sitemap)
331
+ return checkSitemap(args, loaded);
332
+ if (args.feed || looksLikeFeed(loaded.text, args.target, loaded.contentType))
333
+ return checkFeed(args, loaded);
334
+ const r = parsePage(loaded.text, loaded.url);
335
+ console.log(args.json ? JSON.stringify(r, null, 2) : describe(loaded.label, r));
122
336
  return r.brief ? 0 : 1;
123
337
  }
124
- function main(argv) {
338
+ function usage(args) {
339
+ if (args.bad)
340
+ console.error(`${args.bad}\n`);
341
+ console.error(USAGE);
342
+ return 2;
343
+ }
344
+ function tags(args) {
345
+ const format = args.format ?? "html";
346
+ if (!args.target || args.bad || !["html", "feed", "json"].includes(format)) {
347
+ if (!args.bad && args.target)
348
+ args.bad = `--format is html, feed or json, not ${format}`;
349
+ return usage(args);
350
+ }
351
+ let text;
352
+ try {
353
+ text = readFileSync(args.target === "-" ? 0 : args.target, "utf8");
354
+ }
355
+ catch (err) {
356
+ console.error(`Cannot read ${safe(args.target)}: ${err instanceof Error ? err.message : String(err)}`);
357
+ return 2;
358
+ }
359
+ const b = parseBriefing(text);
360
+ for (const e of b.errors)
361
+ console.error(`error ${safe(e)}`);
362
+ for (const w of b.warnings)
363
+ console.error(`warning ${safe(w)}`);
364
+ if (!b.brief)
365
+ return 1;
366
+ if (b.status !== "approved" && !args.allowDraft) {
367
+ console.error("This file is a draft. Set status: approved once the author has read every line, or pass --allow-draft.");
368
+ return 1;
369
+ }
370
+ if (format === "json") {
371
+ console.log(JSON.stringify({ status: b.status, fingerprint: fingerprint(b.brief), brief: b.brief, warnings: b.warnings }, null, 2));
372
+ return 0;
373
+ }
374
+ const draft = b.status !== "approved";
375
+ const out = renderTags(b.brief, format);
376
+ if (args.marked && format === "html")
377
+ console.log(markTags(out, draft));
378
+ else
379
+ console.log(draft ? `<!-- obs: a draft, not yet approved by the author -->\n${out}` : out);
380
+ return 0;
381
+ }
382
+ async function ghost(args) {
383
+ const action = args.target;
384
+ const folder = args.rest[0];
385
+ if (args.bad || (action !== "pull" && action !== "push") || !folder)
386
+ return usage(args);
387
+ const url = process.env.GHOST_URL;
388
+ const key = process.env.GHOST_ADMIN_API_KEY;
389
+ if (!url || !key) {
390
+ console.error("Set GHOST_URL (the site's address) and GHOST_ADMIN_API_KEY (Ghost Admin, Integrations, a custom integration's Admin API key).");
391
+ return 2;
392
+ }
393
+ const { Ghost, pull, push } = await import("./ghost.js");
394
+ const client = new Ghost({ url, key });
395
+ const opts = { dryRun: args.dryRun };
396
+ if (args.slug !== undefined)
397
+ opts.slug = args.slug;
398
+ const lines = action === "pull" ? await pull(client, folder, args.slug) : await push(client, folder, opts);
399
+ for (const l of lines)
400
+ console.log(`${l.state.padEnd(12)} ${safe(l.slug)}${l.note ? ` (${safe(l.note)})` : ""}`);
401
+ const count = (s) => lines.filter((l) => l.state === s).length;
402
+ const states = [...new Set(lines.map((l) => l.state))];
403
+ console.log(lines.length === 0 ? "Nothing to do." : `\n${states.map((s) => `${count(s)} ${s}`).join(", ")}`);
404
+ return count("error") > 0 ? 1 : 0;
405
+ }
406
+ async function main(argv) {
125
407
  const args = parseArgs(argv);
126
408
  if (args.command === "check")
127
409
  return check(args);
410
+ if (args.command === "tags")
411
+ return tags(args);
412
+ if (args.command === "ghost")
413
+ return ghost(args);
414
+ if (args.command === "version") {
415
+ console.log(VERSION);
416
+ return 0;
417
+ }
128
418
  console.error(USAGE);
129
419
  return args.command === "help" ? 0 : 2;
130
420
  }
131
- process.exitCode = main(process.argv.slice(2));
421
+ main(process.argv.slice(2)).then((code) => {
422
+ process.exitCode = code;
423
+ }, (err) => {
424
+ console.error(err instanceof Error ? err.message : String(err));
425
+ process.exitCode = 2;
426
+ });
@@ -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
+ }
@@ -0,0 +1,61 @@
1
+ import { type ObsBrief } from "./obs.js";
2
+ export interface GhostPost {
3
+ id: string;
4
+ slug: string;
5
+ title: string;
6
+ url: string;
7
+ updated_at: string;
8
+ codeinjection_head: string | null;
9
+ plaintext?: string | null;
10
+ }
11
+ export type FetchLike = (url: string, init: {
12
+ method: string;
13
+ headers: Record<string, string>;
14
+ body?: string;
15
+ }) => Promise<{
16
+ ok: boolean;
17
+ status: number;
18
+ json(): Promise<unknown>;
19
+ text(): Promise<string>;
20
+ }>;
21
+ export interface GhostConfig {
22
+ /** The site's address, such as https://example.com. */
23
+ url: string;
24
+ /** An Admin API key, "id:secret". */
25
+ key: string;
26
+ fetch?: FetchLike;
27
+ now?: () => number;
28
+ }
29
+ /** A short-lived token for Ghost's Admin API, signed with the key's secret. */
30
+ export declare function ghostToken(key: string, nowMs?: number): string;
31
+ export declare class Ghost {
32
+ private readonly config;
33
+ private readonly base;
34
+ private readonly fetcher;
35
+ constructor(config: GhostConfig);
36
+ private call;
37
+ /** Every published post, with its plain text. */
38
+ posts(slug?: string): Promise<GhostPost[]>;
39
+ /** Writes a post's Code injection header and nothing else. `updated_at` is Ghost's guard against overwriting a newer edit. */
40
+ setHead(post: GhostPost, head: string): Promise<void>;
41
+ }
42
+ /** The brief a post's Code injection header carries now, when it has usable tags. */
43
+ export declare function briefInHead(head: string, url: string): ObsBrief | undefined;
44
+ /**
45
+ * The header with the new block in place. Tags written by hand outside the
46
+ * markers are taken out first, so the page never carries two sets. Returns
47
+ * undefined when hand-written tags remain that this cannot safely move.
48
+ */
49
+ export declare function headWith(head: string, block: string): string | undefined;
50
+ export interface Line {
51
+ slug: string;
52
+ state: "written" | "would write" | "unchanged" | "draft" | "skipped" | "pulled" | "kept" | "error";
53
+ note?: string;
54
+ }
55
+ /** Writes one file per published post into `folder`. A file that already exists is never touched. */
56
+ export declare function pull(ghost: Ghost, folder: string, slug?: string): Promise<Line[]>;
57
+ /** Writes each approved file's tags into its post. A draft is never written. */
58
+ export declare function push(ghost: Ghost, folder: string, options?: {
59
+ slug?: string;
60
+ dryRun?: boolean;
61
+ }): Promise<Line[]>;