openbriefing 0.3.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 CHANGED
@@ -21,6 +21,47 @@ npx openbriefing check https://example.com/sitemap.xml --sitemap
21
21
 
22
22
  Fetching is bounded: `http` and `https` only, 10 seconds, 5 MB, 5 redirects, and it names itself `openbriefing-check/<version>`.
23
23
 
24
+ ## Write your tags
25
+
26
+ A briefing file is a few lines of YAML that you, or your assistant, write for a post. `tags` turns it into escaped tags.
27
+
28
+ ```yaml
29
+ url: https://example.com/a-post/
30
+ headline: One or two lines to hear before going further.
31
+ summary: |
32
+ Your summary, in plain text. Around 200 to 350 words.
33
+
34
+ A blank line is a paragraph break.
35
+ keep:
36
+ - The one sentence every version must get across.
37
+ highlights:
38
+ - text: A passage from the post, word for word.
39
+ label: On control
40
+ attribution: From Example Blog
41
+ status: approved
42
+ ```
43
+
44
+ ```sh
45
+ npx openbriefing tags a-post.yaml # meta tags for the page's head
46
+ npx openbriefing tags a-post.yaml --format feed # elements for an RSS item or Atom entry
47
+ npx openbriefing tags content/a-post.md # the same keys under obs: in a post's front matter
48
+ ```
49
+
50
+ A file whose `status` is not `approved` is refused (exit 1) unless you pass `--allow-draft`, so a build cannot publish a draft by accident. A drafting prompt for your assistant, and a builder that does the same in the browser, are at [openbriefingstandard.org/publish](https://openbriefingstandard.org/publish).
51
+
52
+ ### Ghost
53
+
54
+ ```sh
55
+ export GHOST_URL=https://your-site.example
56
+ export GHOST_ADMIN_API_KEY=… # Ghost Admin → Integrations → add a custom integration
57
+
58
+ npx openbriefing ghost pull posts/ # one file per published post, with its text
59
+ npx openbriefing ghost push posts/ --dry-run # say what would be written
60
+ npx openbriefing ghost push posts/ # write the approved ones
61
+ ```
62
+
63
+ `pull` never changes Ghost and never overwrites a file. `push` writes only approved files, only between `<!-- obs:start -->` and `<!-- obs:end -->` in the post's Code injection header, and skips a post whose tags have not changed. The folder holds your posts: keep it out of public repositories.
64
+
24
65
  ## Use the parser
25
66
 
26
67
  ```ts
@@ -0,0 +1,46 @@
1
+ import { type ObsBrief } from "./obs.js";
2
+ export type BriefingStatus = "draft" | "approved";
3
+ export interface Briefing {
4
+ /** "draft" unless the file says `status: approved`. */
5
+ status: BriefingStatus;
6
+ /** Absent when the file has no usable summary. */
7
+ brief?: ObsBrief;
8
+ errors: string[];
9
+ warnings: string[];
10
+ /** The post's slug, from the file's front matter, when given. */
11
+ slug?: string;
12
+ /** The post's own text, when the file is a post with front matter. */
13
+ body?: string;
14
+ }
15
+ /** Reads a briefing file, or a post whose front matter has an `obs:` key. Never throws. */
16
+ export declare function parseBriefing(text: string): Briefing;
17
+ /** Marks each highlight found or not found in the post's text, as `check` does for a page (§7). */
18
+ export declare function checkHighlights(brief: ObsBrief, postText: string, warnings: string[]): void;
19
+ export declare function escapeAttr(value: string): string;
20
+ export type TagFormat = "html" | "feed";
21
+ /**
22
+ * A brief written as tags, every character escaped: meta tags for a page's
23
+ * head, or the elements of §14 for an RSS item or Atom entry.
24
+ */
25
+ export declare function renderTags(brief: ObsBrief, format?: TagFormat): string;
26
+ export declare const MARK_START = "<!-- obs:start -->";
27
+ export declare const MARK_END = "<!-- obs:end -->";
28
+ export declare const DRAFT_NOTE = "<!-- obs: a draft, not yet approved by the author -->";
29
+ /** Tags between the obs:start and obs:end comments. */
30
+ export declare function markTags(tags: string, draft?: boolean): string;
31
+ /** The marked block in a field, markers included, when there is one. */
32
+ export declare function readMarked(field: string): string | undefined;
33
+ /**
34
+ * Puts a marked block into a field. An existing block is replaced where it
35
+ * stands; otherwise the block goes at the end. Nothing outside the markers
36
+ * changes.
37
+ */
38
+ export declare function replaceMarked(field: string, block: string): string;
39
+ /** The briefing-file keys for a brief, in the order a person reads them. */
40
+ export declare function briefingFields(brief: ObsBrief, status: BriefingStatus): Record<string, unknown>;
41
+ /** A briefing file's YAML for the given keys. */
42
+ export declare function briefingYaml(fields: Record<string, unknown>): string;
43
+ /** A post file: front matter with the post's details and an `obs:` key, then the post's text. */
44
+ export declare function postFile(front: Record<string, unknown>, body: string): string;
45
+ /** The same keys as front matter for a static site: an `obs:` block to paste into a post. */
46
+ export declare function frontMatterBlock(brief: ObsBrief): string;
@@ -0,0 +1,343 @@
1
+ /**
2
+ * The briefing file: a small YAML file (or the same keys under `obs:` in a
3
+ * post's front matter) that a person or an assistant writes, and the tags it
4
+ * turns into. A tool convention, not part of the standard.
5
+ *
6
+ * Browser-safe: no platform API, so the site's tag builder runs the same code.
7
+ */
8
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
9
+ import { looseText, validateObs } from "./obs.js";
10
+ const KEYS = new Set([
11
+ "url",
12
+ "title",
13
+ "headline",
14
+ "summary",
15
+ "summary_short",
16
+ "summary_medium",
17
+ "summary_long",
18
+ "keep",
19
+ "highlights",
20
+ "attribution",
21
+ "derive",
22
+ "translate",
23
+ "combine",
24
+ "preference",
25
+ "lang",
26
+ "modified",
27
+ "status",
28
+ ]);
29
+ const FRONT_MATTER = /^?---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n([\s\S]*))?$/u;
30
+ /** A block of text as one value: a wrapped line is a space, a blank line is a paragraph break. */
31
+ function paragraphs(v) {
32
+ return v
33
+ .replace(/\r\n?/gu, "\n")
34
+ .split(/\n[ \t]*\n+/u)
35
+ .map((p) => p.replace(/\s*\n\s*/gu, " ").trim())
36
+ .filter((p) => p !== "")
37
+ .join("\n\n");
38
+ }
39
+ const yesNo = (v) => {
40
+ if (typeof v === "boolean")
41
+ return v;
42
+ if (typeof v === "string") {
43
+ const s = v.trim().toLowerCase();
44
+ if (s === "yes" || s === "true")
45
+ return true;
46
+ if (s === "no" || s === "false")
47
+ return false;
48
+ }
49
+ return undefined;
50
+ };
51
+ /** Reads a briefing file, or a post whose front matter has an `obs:` key. Never throws. */
52
+ export function parseBriefing(text) {
53
+ const out = { status: "draft", errors: [], warnings: [] };
54
+ let data;
55
+ let fields;
56
+ try {
57
+ const fm = FRONT_MATTER.exec(text);
58
+ if (fm) {
59
+ data = parseYaml(fm[1]);
60
+ const body = (fm[2] ?? "").trim();
61
+ if (body !== "")
62
+ out.body = body;
63
+ const rec = data !== null && typeof data === "object" ? data : {};
64
+ if (rec.obs === undefined || rec.obs === null) {
65
+ out.errors.push("The front matter has no obs: key");
66
+ return out;
67
+ }
68
+ fields = rec.obs;
69
+ if (typeof rec.slug === "string" && rec.slug.trim() !== "")
70
+ out.slug = rec.slug.trim();
71
+ }
72
+ else {
73
+ data = parseYaml(text);
74
+ fields = data !== null && typeof data === "object" && !Array.isArray(data) && "obs" in data && Object.keys(data).length === 1 ? data.obs : data;
75
+ }
76
+ }
77
+ catch (err) {
78
+ out.errors.push(`The file is not valid YAML: ${err instanceof Error ? (err.message.split("\n")[0] ?? err.message) : String(err)}`);
79
+ return out;
80
+ }
81
+ if (fields === null || typeof fields !== "object" || Array.isArray(fields)) {
82
+ out.errors.push("Expected keys such as summary: and headline:");
83
+ return out;
84
+ }
85
+ const f = fields;
86
+ for (const key of Object.keys(f))
87
+ if (!KEYS.has(key))
88
+ out.warnings.push(`${key}: is not a briefing file key; it is ignored`);
89
+ const status = typeof f.status === "string" ? f.status.trim().toLowerCase() : undefined;
90
+ if (status === "approved")
91
+ out.status = "approved";
92
+ else if (status !== undefined && status !== "draft")
93
+ out.warnings.push(`status: "${String(f.status)}" is not draft or approved; the file is treated as a draft`);
94
+ const text1 = (key) => {
95
+ const v = f[key];
96
+ if (v === undefined || v === null)
97
+ return undefined;
98
+ if (typeof v === "string")
99
+ return paragraphs(v);
100
+ if (typeof v === "number" || typeof v === "boolean")
101
+ return String(v);
102
+ if (v instanceof Date)
103
+ return v.toISOString();
104
+ out.warnings.push(`${key}: is not text; it is ignored`);
105
+ return undefined;
106
+ };
107
+ const shape = { version: 1 };
108
+ for (const key of ["url", "title", "headline", "attribution", "preference", "lang", "modified"]) {
109
+ const v = text1(key);
110
+ if (v !== undefined && v !== "")
111
+ shape[key] = v;
112
+ }
113
+ const summaries = [];
114
+ const plain = text1("summary");
115
+ if (plain)
116
+ summaries.push({ text: plain, from: "inferred" });
117
+ for (const tier of ["short", "medium", "long"]) {
118
+ const v = text1(`summary_${tier}`);
119
+ if (v)
120
+ summaries.push({ tier, text: v, from: "tiered" });
121
+ }
122
+ shape.summaries = summaries;
123
+ const keep = f.keep;
124
+ if (typeof keep === "string")
125
+ shape.keep = [paragraphs(keep)];
126
+ else if (Array.isArray(keep))
127
+ shape.keep = keep.filter((k) => typeof k === "string").map(paragraphs);
128
+ else if (keep !== undefined && keep !== null)
129
+ out.warnings.push("keep: is not a list of lines; it is ignored");
130
+ const highlights = f.highlights;
131
+ if (Array.isArray(highlights)) {
132
+ shape.highlights = highlights.flatMap((h) => {
133
+ if (typeof h === "string")
134
+ return [{ text: paragraphs(h) }];
135
+ if (h !== null && typeof h === "object" && typeof h.text === "string") {
136
+ const rec = h;
137
+ return [typeof rec.label === "string" ? { text: paragraphs(rec.text), label: paragraphs(rec.label) } : { text: paragraphs(rec.text) }];
138
+ }
139
+ out.warnings.push("A highlight has no text; it is ignored");
140
+ return [];
141
+ });
142
+ }
143
+ else if (highlights !== undefined && highlights !== null)
144
+ out.warnings.push("highlights: is not a list; it is ignored");
145
+ const derive = {};
146
+ const mode = text1("derive");
147
+ if (mode !== undefined)
148
+ derive.mode = mode;
149
+ for (const key of ["translate", "combine"]) {
150
+ if (f[key] === undefined || f[key] === null)
151
+ continue;
152
+ const v = yesNo(f[key]);
153
+ if (v === undefined)
154
+ out.warnings.push(`${key}: is not yes or no; it is ignored`);
155
+ else
156
+ derive[key] = v;
157
+ }
158
+ if (Object.keys(derive).length > 0)
159
+ shape.derive = derive;
160
+ const result = validateObs(shape);
161
+ out.errors.push(...result.errors);
162
+ out.warnings.push(...result.warnings);
163
+ if (result.brief) {
164
+ out.brief = result.brief;
165
+ // The briefing file states the language or leaves it out; there is no page to fall back on.
166
+ if (shape.lang === undefined)
167
+ delete out.brief.lang;
168
+ if (out.body !== undefined)
169
+ checkHighlights(out.brief, out.body, out.warnings);
170
+ }
171
+ return out;
172
+ }
173
+ /** Marks each highlight found or not found in the post's text, as `check` does for a page (§7). */
174
+ export function checkHighlights(brief, postText, warnings) {
175
+ const body = looseText(postText);
176
+ if (body === "")
177
+ return;
178
+ for (const h of brief.highlights) {
179
+ h.inContent = body.includes(looseText(h.text));
180
+ if (!h.inContent) {
181
+ const head = h.text.split(/\s+/u).slice(0, 6).join(" ");
182
+ warnings.push(`obs:highlight "${head}…" is not in the post's text (§7)`);
183
+ }
184
+ }
185
+ }
186
+ // ---------------------------------------------------------------------------
187
+ // Writing tags
188
+ export function escapeAttr(value) {
189
+ return value.replace(/&/gu, "&amp;").replace(/"/gu, "&quot;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;").replace(/\n/gu, "&#10;");
190
+ }
191
+ const escapeXml = (value) => value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
192
+ const escapeXmlAttr = (value) => escapeXml(value).replace(/"/gu, "&quot;").replace(/\n/gu, "&#10;");
193
+ /** One [property, value] pair per tag, in the order the tags are written. Defaults are left out. */
194
+ function pairs(brief) {
195
+ const out = [];
196
+ if (brief.version !== 1)
197
+ out.push(["obs:version", String(brief.version)]);
198
+ if (brief.title !== undefined)
199
+ out.push(["obs:title", brief.title]);
200
+ if (brief.headline !== undefined)
201
+ out.push(["obs:headline", brief.headline]);
202
+ for (const s of brief.summaries)
203
+ out.push([s.from === "tiered" ? `obs:summary:${s.tier}` : "obs:summary", s.text]);
204
+ for (const k of brief.keep)
205
+ out.push(["obs:keep", k]);
206
+ for (const h of brief.highlights) {
207
+ out.push(["obs:highlight", h.text]);
208
+ if (h.label !== undefined)
209
+ out.push(["obs:highlight:label", h.label]);
210
+ }
211
+ if (brief.derive.mode !== "anchor")
212
+ out.push(["obs:derive", brief.derive.mode]);
213
+ if (!brief.derive.translate)
214
+ out.push(["obs:derive:translate", "no"]);
215
+ if (!brief.derive.combine)
216
+ out.push(["obs:derive:combine", "no"]);
217
+ if (brief.attribution !== undefined)
218
+ out.push(["obs:attribution", brief.attribution]);
219
+ if (brief.preference !== "summary")
220
+ out.push(["obs:preference", brief.preference]);
221
+ if (brief.lang !== undefined)
222
+ out.push(["obs:lang", brief.lang]);
223
+ if (brief.url !== undefined)
224
+ out.push(["obs:url", brief.url]);
225
+ if (brief.modified !== undefined && brief.modifiedFrom === "obs")
226
+ out.push(["obs:modified", brief.modified]);
227
+ return out;
228
+ }
229
+ /**
230
+ * A brief written as tags, every character escaped: meta tags for a page's
231
+ * head, or the elements of §14 for an RSS item or Atom entry.
232
+ */
233
+ export function renderTags(brief, format = "html") {
234
+ const list = pairs(brief);
235
+ if (format === "html")
236
+ return list.map(([k, v]) => `<meta property="${k}" content="${escapeAttr(v)}">`).join("\n");
237
+ const out = [];
238
+ const derive = [];
239
+ let mode = "";
240
+ for (let i = 0; i < list.length; i++) {
241
+ const [k, v] = list[i];
242
+ const name = k.slice(4);
243
+ if (k === "obs:highlight") {
244
+ const next = list[i + 1];
245
+ const label = next && next[0] === "obs:highlight:label" ? ` label="${escapeXmlAttr(next[1])}"` : "";
246
+ out.push(`<obs:highlight${label}>${escapeXml(v)}</obs:highlight>`);
247
+ }
248
+ else if (k === "obs:highlight:label")
249
+ continue;
250
+ else if (k.startsWith("obs:summary:"))
251
+ out.push(`<obs:summary length="${k.slice(12)}">${escapeXml(v)}</obs:summary>`);
252
+ else if (k === "obs:derive")
253
+ mode = v;
254
+ else if (k === "obs:derive:translate" || k === "obs:derive:combine")
255
+ derive.push(` ${k.slice(11)}="${v}"`);
256
+ else
257
+ out.push(`<obs:${name}>${escapeXml(v)}</obs:${name}>`);
258
+ }
259
+ if (mode !== "" || derive.length > 0) {
260
+ const at = out.findIndex((l) => l.startsWith("<obs:attribution>") || l.startsWith("<obs:preference>") || l.startsWith("<obs:lang>") || l.startsWith("<obs:url>") || l.startsWith("<obs:modified>"));
261
+ const line = `<obs:derive${derive.join("")}>${mode === "" ? "anchor" : mode}</obs:derive>`;
262
+ if (at === -1)
263
+ out.push(line);
264
+ else
265
+ out.splice(at, 0, line);
266
+ }
267
+ return out.join("\n");
268
+ }
269
+ // ---------------------------------------------------------------------------
270
+ // The marked block, for a CMS field that holds other code too
271
+ export const MARK_START = "<!-- obs:start -->";
272
+ export const MARK_END = "<!-- obs:end -->";
273
+ export const DRAFT_NOTE = "<!-- obs: a draft, not yet approved by the author -->";
274
+ const MARKED = /<!--\s*obs:start\s*-->[\s\S]*?<!--\s*obs:end\s*-->/u;
275
+ /** Tags between the obs:start and obs:end comments. */
276
+ export function markTags(tags, draft = false) {
277
+ return [MARK_START, ...(draft ? [DRAFT_NOTE] : []), tags, MARK_END].join("\n");
278
+ }
279
+ /** The marked block in a field, markers included, when there is one. */
280
+ export function readMarked(field) {
281
+ return MARKED.exec(field)?.[0];
282
+ }
283
+ /**
284
+ * Puts a marked block into a field. An existing block is replaced where it
285
+ * stands; otherwise the block goes at the end. Nothing outside the markers
286
+ * changes.
287
+ */
288
+ export function replaceMarked(field, block) {
289
+ if (MARKED.test(field))
290
+ return field.replace(MARKED, () => block);
291
+ if (field.trim() === "")
292
+ return block;
293
+ return `${field.replace(/\s+$/u, "")}\n${block}`;
294
+ }
295
+ // ---------------------------------------------------------------------------
296
+ // Writing a briefing file
297
+ /** The briefing-file keys for a brief, in the order a person reads them. */
298
+ export function briefingFields(brief, status) {
299
+ const f = {};
300
+ if (brief.url !== undefined)
301
+ f.url = brief.url;
302
+ if (brief.title !== undefined)
303
+ f.title = brief.title;
304
+ if (brief.headline !== undefined)
305
+ f.headline = brief.headline;
306
+ for (const s of brief.summaries)
307
+ f[s.from === "tiered" ? `summary_${s.tier}` : "summary"] = s.text;
308
+ if (brief.keep.length > 0)
309
+ f.keep = [...brief.keep];
310
+ if (brief.highlights.length > 0)
311
+ f.highlights = brief.highlights.map((h) => (h.label === undefined ? { text: h.text } : { text: h.text, label: h.label }));
312
+ if (brief.attribution !== undefined)
313
+ f.attribution = brief.attribution;
314
+ if (brief.derive.mode !== "anchor")
315
+ f.derive = brief.derive.mode;
316
+ if (!brief.derive.translate)
317
+ f.translate = false;
318
+ if (!brief.derive.combine)
319
+ f.combine = false;
320
+ if (brief.preference !== "summary")
321
+ f.preference = brief.preference;
322
+ if (brief.lang !== undefined)
323
+ f.lang = brief.lang;
324
+ if (brief.modified !== undefined && brief.modifiedFrom === "obs")
325
+ f.modified = brief.modified;
326
+ f.status = status;
327
+ return f;
328
+ }
329
+ const YAML_OPTIONS = { lineWidth: 0, blockQuote: "literal" };
330
+ /** A briefing file's YAML for the given keys. */
331
+ export function briefingYaml(fields) {
332
+ return stringifyYaml(fields, YAML_OPTIONS);
333
+ }
334
+ /** A post file: front matter with the post's details and an `obs:` key, then the post's text. */
335
+ export function postFile(front, body) {
336
+ return `---\n${stringifyYaml(front, YAML_OPTIONS)}---\n\n${body.trim()}\n`;
337
+ }
338
+ /** The same keys as front matter for a static site: an `obs:` block to paste into a post. */
339
+ export function frontMatterBlock(brief) {
340
+ const fields = briefingFields(brief, "approved");
341
+ delete fields.status;
342
+ return stringifyYaml({ obs: fields }, YAML_OPTIONS).trimEnd();
343
+ }
package/dist/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  /**
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]
3
6
  * openbriefing check <url | file | -> [--url <page url>] [--json]
4
7
  * openbriefing check <feed url | file> --feed [--pages]
5
8
  * openbriefing check <sitemap url | file> --sitemap [--limit <n>]
@@ -11,6 +14,7 @@ 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";
17
+ import { markTags, parseBriefing, renderTags } from "./briefing.js";
14
18
  import { DEFAULT_LIMITS, fetchText, isHttpUrl, mapLimit, userAgent } from "./fetch.js";
15
19
  import { countWords, fingerprint, mergeObs, parseFeedObs, parseObs, SIZES } from "./obs.js";
16
20
  const VERSION = (() => {
@@ -23,11 +27,22 @@ const VERSION = (() => {
23
27
  })();
24
28
  const LIMITS = { ...DEFAULT_LIMITS, userAgent: userAgent(VERSION) };
25
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]
26
33
  openbriefing check <url | file | -> [--url <page url>] [--json]
27
34
  openbriefing check <feed url | file> --feed [--pages]
28
35
  openbriefing check <sitemap url | file> --sitemap [--limit <n>]
29
36
 
30
- Checks a page, a feed or a sitemap for Open Briefing Standard tags.
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
31
46
  --url the page's address, when checking a file with no canonical link
32
47
  --feed treat the input as an RSS or Atom feed (detected when omitted)
33
48
  --pages with a feed: also fetch each item's page and say which one stands
@@ -36,7 +51,7 @@ Checks a page, a feed or a sitemap for Open Briefing Standard tags.
36
51
  --json print the parsed result as JSON
37
52
  Spec: https://openbriefingstandard.org`;
38
53
  function parseArgs(argv) {
39
- const args = { json: false, feed: false, pages: false, sitemap: false };
54
+ const args = { json: false, feed: false, pages: false, sitemap: false, rest: [], marked: false, allowDraft: false, dryRun: false };
40
55
  for (let i = 0; i < argv.length; i++) {
41
56
  const a = argv[i];
42
57
  if (a === "--json")
@@ -47,6 +62,21 @@ function parseArgs(argv) {
47
62
  args.pages = true;
48
63
  else if (a === "--sitemap")
49
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
+ }
50
80
  else if (a === "--url") {
51
81
  const v = argv[++i];
52
82
  if (v === undefined)
@@ -71,6 +101,8 @@ function parseArgs(argv) {
71
101
  args.command = a;
72
102
  else if (args.target === undefined)
73
103
  args.target = a;
104
+ else
105
+ args.rest.push(a);
74
106
  }
75
107
  return args;
76
108
  }
@@ -303,10 +335,82 @@ async function check(args) {
303
335
  console.log(args.json ? JSON.stringify(r, null, 2) : describe(loaded.label, r));
304
336
  return r.brief ? 0 : 1;
305
337
  }
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
+ }
306
406
  async function main(argv) {
307
407
  const args = parseArgs(argv);
308
408
  if (args.command === "check")
309
409
  return check(args);
410
+ if (args.command === "tags")
411
+ return tags(args);
412
+ if (args.command === "ghost")
413
+ return ghost(args);
310
414
  if (args.command === "version") {
311
415
  console.log(VERSION);
312
416
  return 0;
@@ -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[]>;
package/dist/ghost.js ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * `openbriefing ghost pull` and `ghost push`: briefing files to and from a
3
+ * Ghost site, through its Admin API. Push writes one thing, the block between
4
+ * the obs:start and obs:end comments in a post's Code injection header.
5
+ *
6
+ * The key comes from the environment and is never printed.
7
+ */
8
+ import { createHmac } from "node:crypto";
9
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { JSDOM } from "jsdom";
12
+ import { briefingFields, markTags, parseBriefing, postFile, readMarked, renderTags, replaceMarked } from "./briefing.js";
13
+ import { fingerprint, parseObs } from "./obs.js";
14
+ const b64url = (input) => Buffer.from(input).toString("base64url");
15
+ /** A short-lived token for Ghost's Admin API, signed with the key's secret. */
16
+ export function ghostToken(key, nowMs = Date.now()) {
17
+ const [id, secret] = key.split(":");
18
+ if (!id || !secret || !/^[0-9a-f]+$/iu.test(secret))
19
+ throw new Error("GHOST_ADMIN_API_KEY should look like id:secret, as Ghost shows it");
20
+ const iat = Math.floor(nowMs / 1000);
21
+ const head = b64url(JSON.stringify({ alg: "HS256", typ: "JWT", kid: id }));
22
+ const body = b64url(JSON.stringify({ iat, exp: iat + 300, aud: "/admin/" }));
23
+ const sig = createHmac("sha256", Buffer.from(secret, "hex")).update(`${head}.${body}`).digest("base64url");
24
+ return `${head}.${body}.${sig}`;
25
+ }
26
+ export class Ghost {
27
+ config;
28
+ base;
29
+ fetcher;
30
+ constructor(config) {
31
+ this.config = config;
32
+ this.base = `${config.url.replace(/\/+$/u, "")}/ghost/api/admin`;
33
+ this.fetcher = config.fetch ?? ((url, init) => fetch(url, init));
34
+ }
35
+ async call(method, path, body) {
36
+ const headers = {
37
+ authorization: `Ghost ${ghostToken(this.config.key, this.config.now?.() ?? Date.now())}`,
38
+ "accept-version": "v5.0",
39
+ accept: "application/json",
40
+ };
41
+ const init = { method, headers };
42
+ if (body !== undefined) {
43
+ headers["content-type"] = "application/json";
44
+ init.body = JSON.stringify(body);
45
+ }
46
+ const res = await this.fetcher(`${this.base}${path}`, init);
47
+ if (!res.ok) {
48
+ let detail = "";
49
+ try {
50
+ const err = (await res.json());
51
+ detail = err.errors?.[0]?.message ?? "";
52
+ }
53
+ catch {
54
+ /* the status says enough */
55
+ }
56
+ throw new Error(`Ghost answered ${res.status}${detail ? `: ${detail}` : ""}`);
57
+ }
58
+ return res.json();
59
+ }
60
+ /** Every published post, with its plain text. */
61
+ async posts(slug) {
62
+ const out = [];
63
+ const filter = slug ? `status:published+slug:${slug}` : "status:published";
64
+ for (let page = 1; page <= 200; page++) {
65
+ const res = (await this.call("GET", `/posts/?filter=${encodeURIComponent(filter)}&formats=plaintext&limit=50&page=${page}&order=${encodeURIComponent("published_at desc")}`));
66
+ out.push(...(res.posts ?? []));
67
+ if (page >= (res.meta?.pagination?.pages ?? 1))
68
+ break;
69
+ }
70
+ return out;
71
+ }
72
+ /** Writes a post's Code injection header and nothing else. `updated_at` is Ghost's guard against overwriting a newer edit. */
73
+ async setHead(post, head) {
74
+ await this.call("PUT", `/posts/${post.id}/`, { posts: [{ codeinjection_head: head, updated_at: post.updated_at }] });
75
+ }
76
+ }
77
+ const META_OBS = /[ \t]*<meta\s+(?:property|name)\s*=\s*"obs:[^"]*"\s+content\s*=\s*"[^"]*"\s*\/?>[ \t]*\r?\n?/giu;
78
+ /** The brief a post's Code injection header carries now, when it has usable tags. */
79
+ export function briefInHead(head, url) {
80
+ if (!/obs:/u.test(head))
81
+ return undefined;
82
+ return parseObs(new JSDOM(`<!doctype html><html><head>${head}</head><body></body></html>`, { url }).window.document, url).brief;
83
+ }
84
+ /**
85
+ * The header with the new block in place. Tags written by hand outside the
86
+ * markers are taken out first, so the page never carries two sets. Returns
87
+ * undefined when hand-written tags remain that this cannot safely move.
88
+ */
89
+ export function headWith(head, block) {
90
+ const marked = readMarked(head);
91
+ if (marked !== undefined) {
92
+ const outside = head.replace(marked, "");
93
+ if (/obs:(?!start|end)/u.test(outside.replace(META_OBS, "")))
94
+ return undefined;
95
+ return replaceMarked(head.replace(marked, "\u0000").replace(META_OBS, "").replace("\u0000", () => marked), block);
96
+ }
97
+ const cleaned = head.replace(META_OBS, "");
98
+ if (/obs:/u.test(cleaned))
99
+ return undefined;
100
+ return replaceMarked(cleaned, block);
101
+ }
102
+ /** Ghost's slug shape. Anything else is never used as a file name or in a query. */
103
+ const SLUG = /^[a-z0-9][a-z0-9_-]*$/iu;
104
+ const briefingFiles = (folder) => readdirSync(folder)
105
+ .filter((f) => /\.(md|markdown|ya?ml)$/iu.test(f))
106
+ .sort();
107
+ /** Writes one file per published post into `folder`. A file that already exists is never touched. */
108
+ export async function pull(ghost, folder, slug) {
109
+ mkdirSync(folder, { recursive: true });
110
+ const lines = [];
111
+ if (slug !== undefined && !SLUG.test(slug))
112
+ return [{ slug, state: "error", note: "not a post slug" }];
113
+ for (const post of await ghost.posts(slug)) {
114
+ if (typeof post.slug !== "string" || !SLUG.test(post.slug)) {
115
+ lines.push({ slug: String(post.slug), state: "error", note: "the post's slug is not a plain slug, so no file is written" });
116
+ continue;
117
+ }
118
+ const path = join(folder, `${post.slug}.md`);
119
+ if (existsSync(path)) {
120
+ lines.push({ slug: post.slug, state: "kept", note: "the file is already here" });
121
+ continue;
122
+ }
123
+ // Tags already live on the post were approved by whoever put them there.
124
+ const live = briefInHead(post.codeinjection_head ?? "", post.url);
125
+ const obs = live ? briefingFields({ ...live, url: live.url ?? post.url }, "approved") : { url: post.url, status: "draft" };
126
+ writeFileSync(path, postFile({ slug: post.slug, title: post.title, obs }, post.plaintext ?? ""));
127
+ lines.push({ slug: post.slug, state: "pulled", note: live ? "with the tags the post already carries, as approved" : "a draft to fill in" });
128
+ }
129
+ return lines;
130
+ }
131
+ /** Writes each approved file's tags into its post. A draft is never written. */
132
+ export async function push(ghost, folder, options = {}) {
133
+ const lines = [];
134
+ const files = briefingFiles(folder).filter((f) => options.slug === undefined || f.replace(/\.[^.]+$/u, "") === options.slug);
135
+ if (files.length === 0)
136
+ return lines;
137
+ const only = files.length === 1 ? files[0].replace(/\.[^.]+$/u, "") : undefined;
138
+ const posts = new Map((await ghost.posts(only !== undefined && SLUG.test(only) ? only : undefined)).map((p) => [p.slug, p]));
139
+ for (const file of files) {
140
+ const briefing = parseBriefing(readFileSync(join(folder, file), "utf8"));
141
+ const slug = briefing.slug ?? file.replace(/\.[^.]+$/u, "");
142
+ if (briefing.status !== "approved") {
143
+ lines.push({ slug, state: "draft", note: "not approved, so not written" });
144
+ continue;
145
+ }
146
+ if (!briefing.brief) {
147
+ lines.push({ slug, state: "error", note: briefing.errors.join("; ") });
148
+ continue;
149
+ }
150
+ const post = posts.get(slug);
151
+ if (!post) {
152
+ lines.push({ slug, state: "error", note: "no published post has this slug" });
153
+ continue;
154
+ }
155
+ const head = post.codeinjection_head ?? "";
156
+ const marked = readMarked(head);
157
+ const live = marked === undefined ? undefined : briefInHead(marked, post.url);
158
+ const next = headWith(head, markTags(renderTags(briefing.brief)));
159
+ if (next === undefined) {
160
+ lines.push({ slug, state: "skipped", note: "the post has obs: code outside the markers that is not a plain meta tag; move it by hand" });
161
+ continue;
162
+ }
163
+ const outside = marked === undefined ? head : head.replace(marked, "");
164
+ const stray = outside.replace(META_OBS, "") !== outside;
165
+ if (live && !stray && fingerprint(live) === fingerprint(briefing.brief)) {
166
+ lines.push({ slug, state: "unchanged" });
167
+ continue;
168
+ }
169
+ if (options.dryRun) {
170
+ lines.push({ slug, state: "would write" });
171
+ continue;
172
+ }
173
+ try {
174
+ await ghost.setHead(post, next);
175
+ lines.push({ slug, state: "written" });
176
+ }
177
+ catch (err) {
178
+ lines.push({ slug, state: "error", note: err instanceof Error ? err.message : String(err) });
179
+ }
180
+ }
181
+ return lines;
182
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openbriefing",
3
- "version": "0.3.0",
4
- "description": "Open Briefing Standard (OBS): the reference parser and checker for publishers' own summaries.",
3
+ "version": "0.4.0",
4
+ "description": "Open Briefing Standard (OBS): the reference parser, the checker and the publisher's tag tools for publishers' own summaries.",
5
5
  "keywords": [
6
6
  "open-briefing-standard",
7
7
  "obs",
@@ -26,6 +26,10 @@
26
26
  "types": "./dist/index.d.ts",
27
27
  "import": "./dist/index.js"
28
28
  },
29
+ "./briefing": {
30
+ "types": "./dist/briefing.d.ts",
31
+ "import": "./dist/briefing.js"
32
+ },
29
33
  "./fetch": {
30
34
  "types": "./dist/fetch.d.ts",
31
35
  "import": "./dist/fetch.js"
@@ -52,7 +56,8 @@
52
56
  "prepack": "rm -rf fixtures && cp -R ../../spec/fixtures fixtures && npm run build"
53
57
  },
54
58
  "dependencies": {
55
- "jsdom": "^26.1.0"
59
+ "jsdom": "^26.1.0",
60
+ "yaml": "^2.9.1"
56
61
  },
57
62
  "devDependencies": {
58
63
  "@types/jsdom": "^21.1.7",