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/README.md +58 -8
- package/dist/briefing.d.ts +46 -0
- package/dist/briefing.js +343 -0
- package/dist/cli.js +345 -50
- package/dist/fetch.d.ts +33 -0
- package/dist/fetch.js +122 -0
- package/dist/ghost.d.ts +61 -0
- package/dist/ghost.js +182 -0
- package/dist/obs.d.ts +21 -3
- package/dist/obs.js +178 -10
- package/fixtures/expected.json +380 -40
- package/fixtures/feed-modified-from-pubdate.xml +21 -0
- package/fixtures/feed-modified-from-updated.xml +19 -0
- package/fixtures/modified-bad-then-article.html +14 -0
- package/fixtures/modified-from-article.html +13 -0
- package/fixtures/modified-obs-over-article.html +14 -0
- package/fixtures/unescaped-quote.html +12 -0
- package/package.json +12 -3
package/dist/cli.js
CHANGED
|
@@ -1,52 +1,115 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* openbriefing
|
|
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
|
|
6
|
-
*
|
|
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 {
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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 === "--
|
|
28
|
-
args.
|
|
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.
|
|
34
|
-
args.
|
|
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
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
|
|
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 =
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, '"').replace(/'/gu, "'").replace(/&/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
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|
package/dist/fetch.d.ts
ADDED
|
@@ -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/ghost.d.ts
ADDED
|
@@ -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[]>;
|