openbriefing 0.0.1 → 0.1.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 +36 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +123 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/obs.d.ts +86 -0
- package/dist/obs.js +449 -0
- package/fixtures/bad-modified.html +13 -0
- package/fixtures/band-120.html +13 -0
- package/fixtures/band-121.html +13 -0
- package/fixtures/band-500.html +13 -0
- package/fixtures/band-501.html +13 -0
- package/fixtures/body-tags.html +13 -0
- package/fixtures/derive-bad.html +15 -0
- package/fixtures/derive-verbatim.html +15 -0
- package/fixtures/duplicate-headline.html +15 -0
- package/fixtures/expected.json +804 -0
- package/fixtures/feed-atom.xml +16 -0
- package/fixtures/feed-rss.xml +26 -0
- package/fixtures/feed-vs-page.html +13 -0
- package/fixtures/injection.html +13 -0
- package/fixtures/keep-limits.html +18 -0
- package/fixtures/lang-fallback.html +13 -0
- package/fixtures/markup-in-text.html +13 -0
- package/fixtures/missing-modified.html +12 -0
- package/fixtures/missing-summary.html +13 -0
- package/fixtures/name-attribute.html +13 -0
- package/fixtures/newer-version.html +14 -0
- package/fixtures/no-tags.html +12 -0
- package/fixtures/over-sizes.html +15 -0
- package/fixtures/paragraphs.html +13 -0
- package/fixtures/preference-full.html +14 -0
- package/fixtures/tier-collision.html +14 -0
- package/fixtures/tiered-only.html +14 -0
- package/fixtures/unknown-and-reserved.html +15 -0
- package/fixtures/url-other.html +14 -0
- package/fixtures/url-same.html +14 -0
- package/fixtures/valid-full.html +24 -0
- package/fixtures/valid-minimal.html +13 -0
- package/package.json +30 -3
package/README.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# openbriefing
|
|
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
|
-
**
|
|
5
|
+
**Version 0.1.** It contains the parser and `openbriefing check`. Later versions add `generate`, `preview`, `backfill`, `review` and a Ghost integration.
|
|
6
|
+
|
|
7
|
+
## Check a page or a feed
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
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/
|
|
13
|
+
```
|
|
14
|
+
|
|
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
|
+
|
|
17
|
+
## Use the parser
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { JSDOM } from "jsdom";
|
|
21
|
+
import { parseObs } from "openbriefing";
|
|
22
|
+
|
|
23
|
+
const dom = new JSDOM(html, { url });
|
|
24
|
+
const { brief, errors, warnings } = parseObs(dom.window.document, url);
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Export | What it does |
|
|
28
|
+
|--------|--------------|
|
|
29
|
+
| `parseObs(document, pageUrl)` | Reads a page's `obs:` meta tags |
|
|
30
|
+
| `parseFeedObs(itemElement, itemUrl?)` | Reads one RSS `<item>` or Atom `<entry>` |
|
|
31
|
+
| `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 |
|
|
34
|
+
|
|
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.
|
|
36
|
+
|
|
37
|
+
The test fixtures ship with the package in `openbriefing/fixtures/`, with `expected.json`.
|
|
38
|
+
|
|
39
|
+
MIT licence. The spec is dedicated to the public domain (CC0).
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* openbriefing check <file | -> [--url <page url>] [--json]
|
|
4
|
+
*
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import { readFileSync } from "node:fs";
|
|
11
|
+
import { basename, resolve } from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
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]
|
|
16
|
+
|
|
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
|
|
20
|
+
Spec: https://openbriefingstandard.org`;
|
|
21
|
+
function parseArgs(argv) {
|
|
22
|
+
const args = { json: false };
|
|
23
|
+
for (let i = 0; i < argv.length; i++) {
|
|
24
|
+
const a = argv[i];
|
|
25
|
+
if (a === "--json")
|
|
26
|
+
args.json = true;
|
|
27
|
+
else if (a === "--url")
|
|
28
|
+
args.url = argv[++i];
|
|
29
|
+
else if (a === "-h" || a === "--help")
|
|
30
|
+
args.command = "help";
|
|
31
|
+
else if (args.command === undefined)
|
|
32
|
+
args.command = a;
|
|
33
|
+
else if (args.file === undefined)
|
|
34
|
+
args.file = a;
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
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
|
+
/** Page text is untrusted: show control characters (ANSI escapes and the like) instead of sending them to the terminal. */
|
|
48
|
+
const safe = (s) => s.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/gu, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
|
|
49
|
+
const cut = (s, n = 60) => safe((s.length > n ? `${s.slice(0, n - 1)}…` : s).replace(/\n/gu, " "));
|
|
50
|
+
function describe(label, r) {
|
|
51
|
+
const lines = [safe(label)];
|
|
52
|
+
const b = r.brief;
|
|
53
|
+
if (b) {
|
|
54
|
+
const row = (k, v) => lines.push(` ${k.padEnd(12)} ${v}`);
|
|
55
|
+
if (b.title !== undefined)
|
|
56
|
+
row("title", `${cut(b.title)} (${countWords(b.title)}/${SIZES.title} words)`);
|
|
57
|
+
if (b.headline !== undefined)
|
|
58
|
+
row("headline", `${cut(b.headline)} (${countWords(b.headline)}/${SIZES.headline} words)`);
|
|
59
|
+
for (const s of b.summaries) {
|
|
60
|
+
const [lo, hi] = SIZES.tiers[s.tier].target;
|
|
61
|
+
row(`summary`, `${s.tier.padEnd(6)} ${String(s.words).padStart(5)} words (target ${lo}–${hi}) ${s.from}`);
|
|
62
|
+
}
|
|
63
|
+
b.keep.forEach((k, i) => row(i === 0 ? "keep (core)" : `keep ${i + 1}`, cut(k)));
|
|
64
|
+
row("derive", `${b.derive.mode}; translate ${b.derive.translate ? "yes" : "no"}; combine ${b.derive.combine ? "yes" : "no"}`);
|
|
65
|
+
if (b.attribution !== undefined)
|
|
66
|
+
row("attribution", cut(b.attribution));
|
|
67
|
+
row("preference", b.preference);
|
|
68
|
+
if (b.lang !== undefined)
|
|
69
|
+
row("lang", safe(b.lang));
|
|
70
|
+
row("modified", safe(b.modified));
|
|
71
|
+
if (b.url !== undefined)
|
|
72
|
+
row("url", `${safe(b.url)}${b.describesThisPage ? "" : " (describes another page)"}`);
|
|
73
|
+
}
|
|
74
|
+
for (const e of r.errors)
|
|
75
|
+
lines.push(` error ${safe(e)}`);
|
|
76
|
+
for (const w of r.warnings)
|
|
77
|
+
lines.push(` warning ${safe(w)}`);
|
|
78
|
+
lines.push(b ? ` ok: usable, ${r.warnings.length} warning${r.warnings.length === 1 ? "" : "s"}` : " not usable");
|
|
79
|
+
return lines.join("\n");
|
|
80
|
+
}
|
|
81
|
+
function check(args) {
|
|
82
|
+
if (!args.file) {
|
|
83
|
+
console.error(USAGE);
|
|
84
|
+
return 2;
|
|
85
|
+
}
|
|
86
|
+
let text;
|
|
87
|
+
try {
|
|
88
|
+
text = readInput(args.file);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
console.error(`Cannot read ${args.file}: ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
return 2;
|
|
93
|
+
}
|
|
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));
|
|
100
|
+
if (args.json)
|
|
101
|
+
console.log(JSON.stringify(results, null, 2));
|
|
102
|
+
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;
|
|
108
|
+
}
|
|
109
|
+
return results.every((r) => r.brief) ? 0 : 1;
|
|
110
|
+
}
|
|
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));
|
|
114
|
+
return r.brief ? 0 : 1;
|
|
115
|
+
}
|
|
116
|
+
function main(argv) {
|
|
117
|
+
const args = parseArgs(argv);
|
|
118
|
+
if (args.command === "check")
|
|
119
|
+
return check(args);
|
|
120
|
+
console.error(USAGE);
|
|
121
|
+
return args.command === "help" ? 0 : 2;
|
|
122
|
+
}
|
|
123
|
+
process.exitCode = main(process.argv.slice(2));
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./obs.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./obs.js";
|
package/dist/obs.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Open Briefing Standard, version 1: reference parser.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained: no runtime imports. It reads a DOM `Document` or `Element`
|
|
5
|
+
* (jsdom on a server, DOMParser in a browser) and never touches the network.
|
|
6
|
+
* Every function here returns a result rather than throwing.
|
|
7
|
+
*
|
|
8
|
+
* Section numbers in comments refer to spec/v1.md.
|
|
9
|
+
*/
|
|
10
|
+
export declare const OBS_NS = "https://openbriefingstandard.org/ns#";
|
|
11
|
+
export type ObsTier = "short" | "medium" | "long";
|
|
12
|
+
export type DeriveMode = "verbatim" | "anchor" | "open";
|
|
13
|
+
export type Preference = "summary" | "full";
|
|
14
|
+
export interface ObsSummary {
|
|
15
|
+
tier: ObsTier;
|
|
16
|
+
text: string;
|
|
17
|
+
words: number;
|
|
18
|
+
/** "tiered" for obs:summary:{tier}; "inferred" for a bare obs:summary (§4). */
|
|
19
|
+
from: "tiered" | "inferred";
|
|
20
|
+
}
|
|
21
|
+
export interface ObsBrief {
|
|
22
|
+
version: number;
|
|
23
|
+
/** obs:modified, as given (ISO 8601). */
|
|
24
|
+
modified: string;
|
|
25
|
+
/** obs:url, normalized, when given. */
|
|
26
|
+
url?: string;
|
|
27
|
+
/** False when obs:url names content other than this page (§11). */
|
|
28
|
+
describesThisPage: boolean;
|
|
29
|
+
title?: string;
|
|
30
|
+
headline?: string;
|
|
31
|
+
/** Short to long, at most one per tier, at least one. */
|
|
32
|
+
summaries: ObsSummary[];
|
|
33
|
+
/** Priority order, at most 3. keep[0] is the core message (§6). */
|
|
34
|
+
keep: string[];
|
|
35
|
+
derive: {
|
|
36
|
+
mode: DeriveMode;
|
|
37
|
+
translate: boolean;
|
|
38
|
+
combine: boolean;
|
|
39
|
+
};
|
|
40
|
+
attribution?: string;
|
|
41
|
+
preference: Preference;
|
|
42
|
+
lang?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ParseResult {
|
|
45
|
+
/** Absent when nothing usable was found. */
|
|
46
|
+
brief?: ObsBrief;
|
|
47
|
+
/** Why the tags are unusable. */
|
|
48
|
+
errors: string[];
|
|
49
|
+
/** Usable, but something is off. */
|
|
50
|
+
warnings: string[];
|
|
51
|
+
}
|
|
52
|
+
/** Sizes from §4, exported so tools show the same numbers. */
|
|
53
|
+
export declare const SIZES: {
|
|
54
|
+
readonly title: 12;
|
|
55
|
+
readonly headline: 40;
|
|
56
|
+
readonly keep: 40;
|
|
57
|
+
readonly attribution: 12;
|
|
58
|
+
readonly keepMax: 3;
|
|
59
|
+
readonly tiers: Record<ObsTier, {
|
|
60
|
+
target: [number, number];
|
|
61
|
+
}>;
|
|
62
|
+
readonly bands: {
|
|
63
|
+
readonly shortMax: 120;
|
|
64
|
+
readonly mediumMax: 500;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
/** The tier for a word count, by the bands in §4. */
|
|
68
|
+
export declare function tierFor(words: number): ObsTier;
|
|
69
|
+
/** Words are runs of characters between white space (§4). */
|
|
70
|
+
export declare function countWords(text: string): number;
|
|
71
|
+
/** URL comparison of §11: scheme and host case, trailing slash, tracking parameters. */
|
|
72
|
+
export declare function normalizeUrl(input: string, base?: string): string | undefined;
|
|
73
|
+
/**
|
|
74
|
+
* Reads a page's obs: meta tags. `pageUrl` is where the page was fetched
|
|
75
|
+
* from; the page's <link rel="canonical"> takes precedence for §11.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseObs(doc: Document, pageUrl: string): ParseResult;
|
|
78
|
+
/**
|
|
79
|
+
* Reads the OBS elements of one RSS <item> or Atom <entry>. `itemUrl`
|
|
80
|
+
* overrides the item's own link when given.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseFeedObs(item: Element, itemUrl?: string): ParseResult;
|
|
83
|
+
/** Checks an object in the ObsBrief shape and rebuilds it under the same rules. */
|
|
84
|
+
export declare function validateObs(input: unknown): ParseResult;
|
|
85
|
+
/** The later obs:modified stands; on a tie, the page stands. */
|
|
86
|
+
export declare function mergeObs(page?: ObsBrief, feed?: ObsBrief): ObsBrief | undefined;
|