openbriefing 0.3.0 → 0.5.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,59 @@ 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
+
65
+ To have the tags written when a post is published, run the hook where Ghost can reach it:
66
+
67
+ ```bash
68
+ export GHOST_WEBHOOK_SECRET=… # any long random text
69
+ export ANTHROPIC_API_KEY=…
70
+ export OBS_HOOK_URL=https://your-hook.example # registers the two webhooks on start
71
+ npx openbriefing ghost hook # POST /ghost, GET /healthz, on PORT or 8080
72
+ npx openbriefing ghost draft --dry-run # the same for posts already published
73
+ ```
74
+
75
+ The hook drafts with Claude (`OBS_MODEL` picks the model), keeps only highlights it finds in the post, and stamps its block `<!-- obs:auto … -->`. Edit the tags in the post's Code injection and they are yours: the hook never writes to that post again. It leaves members-only posts and posts under 200 words alone. These tags are published before you read them; use `pull` and `push` if you would rather approve each one.
76
+
24
77
  ## Use the parser
25
78
 
26
79
  ```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,10 @@
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]
6
+ * openbriefing ghost draft [--slug <slug>] [--limit <n>] [--dry-run]
7
+ * openbriefing ghost hook [--port <n>]
3
8
  * openbriefing check <url | file | -> [--url <page url>] [--json]
4
9
  * openbriefing check <feed url | file> --feed [--pages]
5
10
  * openbriefing check <sitemap url | file> --sitemap [--limit <n>]
@@ -11,6 +16,7 @@ import { readFileSync } from "node:fs";
11
16
  import { basename, resolve } from "node:path";
12
17
  import { pathToFileURL } from "node:url";
13
18
  import { JSDOM } from "jsdom";
19
+ import { markTags, parseBriefing, renderTags } from "./briefing.js";
14
20
  import { DEFAULT_LIMITS, fetchText, isHttpUrl, mapLimit, userAgent } from "./fetch.js";
15
21
  import { countWords, fingerprint, mergeObs, parseFeedObs, parseObs, SIZES } from "./obs.js";
16
22
  const VERSION = (() => {
@@ -23,11 +29,29 @@ const VERSION = (() => {
23
29
  })();
24
30
  const LIMITS = { ...DEFAULT_LIMITS, userAgent: userAgent(VERSION) };
25
31
  const USAGE = `Usage:
32
+ openbriefing tags <file.yaml | post.md | -> [--format html | feed | json] [--marked] [--allow-draft]
33
+ openbriefing ghost pull <folder> [--slug <slug>]
34
+ openbriefing ghost push <folder> [--slug <slug>] [--dry-run]
35
+ openbriefing ghost draft [--slug <slug>] [--limit <n>] [--dry-run]
36
+ openbriefing ghost hook [--port <n>]
26
37
  openbriefing check <url | file | -> [--url <page url>] [--json]
27
38
  openbriefing check <feed url | file> --feed [--pages]
28
39
  openbriefing check <sitemap url | file> --sitemap [--limit <n>]
29
40
 
30
- Checks a page, a feed or a sitemap for Open Briefing Standard tags.
41
+ tags turns a briefing file into escaped tags. ghost pull writes one file per
42
+ published post; ghost push writes each approved file's tags into its post
43
+ (GHOST_URL and GHOST_ADMIN_API_KEY in the environment). ghost draft writes
44
+ tags, drafted by Claude (ANTHROPIC_API_KEY), for each published post that has
45
+ none. ghost hook does the same when a post is published: it serves Ghost's
46
+ webhooks (GHOST_WEBHOOK_SECRET; OBS_HOOK_URL registers them). The author edits
47
+ the tags in the post's Code injection, and an edited post is left alone.
48
+ check reads a page, a feed or a sitemap and prints what a reader sees.
49
+ --format html (meta tags, the default), feed (RSS and Atom elements) or json
50
+ --marked wrap the tags in the obs:start and obs:end comments
51
+ --allow-draft write tags for a file whose status is not approved
52
+ --slug with ghost: one post only
53
+ --dry-run with ghost push or draft: say what would be written, and write nothing
54
+ --port with ghost hook: the port to listen on (PORT, or 8080)
31
55
  --url the page's address, when checking a file with no canonical link
32
56
  --feed treat the input as an RSS or Atom feed (detected when omitted)
33
57
  --pages with a feed: also fetch each item's page and say which one stands
@@ -36,7 +60,7 @@ Checks a page, a feed or a sitemap for Open Briefing Standard tags.
36
60
  --json print the parsed result as JSON
37
61
  Spec: https://openbriefingstandard.org`;
38
62
  function parseArgs(argv) {
39
- const args = { json: false, feed: false, pages: false, sitemap: false };
63
+ const args = { json: false, feed: false, pages: false, sitemap: false, rest: [], marked: false, allowDraft: false, dryRun: false };
40
64
  for (let i = 0; i < argv.length; i++) {
41
65
  const a = argv[i];
42
66
  if (a === "--json")
@@ -47,6 +71,21 @@ function parseArgs(argv) {
47
71
  args.pages = true;
48
72
  else if (a === "--sitemap")
49
73
  args.sitemap = true;
74
+ else if (a === "--marked")
75
+ args.marked = true;
76
+ else if (a === "--allow-draft")
77
+ args.allowDraft = true;
78
+ else if (a === "--dry-run")
79
+ args.dryRun = true;
80
+ else if (a === "--format" || a === "--slug") {
81
+ const v = argv[++i];
82
+ if (v === undefined)
83
+ args.bad = `${a} needs a value`;
84
+ else if (a === "--format")
85
+ args.format = v;
86
+ else
87
+ args.slug = v;
88
+ }
50
89
  else if (a === "--url") {
51
90
  const v = argv[++i];
52
91
  if (v === undefined)
@@ -54,6 +93,13 @@ function parseArgs(argv) {
54
93
  else
55
94
  args.url = v;
56
95
  }
96
+ else if (a === "--port") {
97
+ const n = Number.parseInt(argv[++i] ?? "", 10);
98
+ if (!Number.isInteger(n) || n < 1 || n > 65535)
99
+ args.bad = "--port needs a port number";
100
+ else
101
+ args.port = n;
102
+ }
57
103
  else if (a === "--limit") {
58
104
  const n = Number.parseInt(argv[++i] ?? "", 10);
59
105
  if (!Number.isInteger(n) || n < 1)
@@ -71,6 +117,8 @@ function parseArgs(argv) {
71
117
  args.command = a;
72
118
  else if (args.target === undefined)
73
119
  args.target = a;
120
+ else
121
+ args.rest.push(a);
74
122
  }
75
123
  return args;
76
124
  }
@@ -303,10 +351,113 @@ async function check(args) {
303
351
  console.log(args.json ? JSON.stringify(r, null, 2) : describe(loaded.label, r));
304
352
  return r.brief ? 0 : 1;
305
353
  }
354
+ function usage(args) {
355
+ if (args.bad)
356
+ console.error(`${args.bad}\n`);
357
+ console.error(USAGE);
358
+ return 2;
359
+ }
360
+ function tags(args) {
361
+ const format = args.format ?? "html";
362
+ if (!args.target || args.bad || !["html", "feed", "json"].includes(format)) {
363
+ if (!args.bad && args.target)
364
+ args.bad = `--format is html, feed or json, not ${format}`;
365
+ return usage(args);
366
+ }
367
+ let text;
368
+ try {
369
+ text = readFileSync(args.target === "-" ? 0 : args.target, "utf8");
370
+ }
371
+ catch (err) {
372
+ console.error(`Cannot read ${safe(args.target)}: ${err instanceof Error ? err.message : String(err)}`);
373
+ return 2;
374
+ }
375
+ const b = parseBriefing(text);
376
+ for (const e of b.errors)
377
+ console.error(`error ${safe(e)}`);
378
+ for (const w of b.warnings)
379
+ console.error(`warning ${safe(w)}`);
380
+ if (!b.brief)
381
+ return 1;
382
+ if (b.status !== "approved" && !args.allowDraft) {
383
+ console.error("This file is a draft. Set status: approved once the author has read every line, or pass --allow-draft.");
384
+ return 1;
385
+ }
386
+ if (format === "json") {
387
+ console.log(JSON.stringify({ status: b.status, fingerprint: fingerprint(b.brief), brief: b.brief, warnings: b.warnings }, null, 2));
388
+ return 0;
389
+ }
390
+ const draft = b.status !== "approved";
391
+ const out = renderTags(b.brief, format);
392
+ if (args.marked && format === "html")
393
+ console.log(markTags(out, draft));
394
+ else
395
+ console.log(draft ? `<!-- obs: a draft, not yet approved by the author -->\n${out}` : out);
396
+ return 0;
397
+ }
398
+ const printLine = (l) => console.log(`${l.state.padEnd(12)} ${safe(l.slug)}${l.note ? ` (${safe(l.note)})` : ""}`);
399
+ function tally(lines) {
400
+ const count = (s) => lines.filter((l) => l.state === s).length;
401
+ const states = [...new Set(lines.map((l) => l.state))];
402
+ console.log(lines.length === 0 ? "Nothing to do." : `\n${states.map((s) => `${count(s)} ${s}`).join(", ")}`);
403
+ return count("error") > 0 ? 1 : 0;
404
+ }
405
+ async function ghost(args) {
406
+ const action = args.target;
407
+ const folder = args.rest[0];
408
+ const filed = action === "pull" || action === "push";
409
+ if (args.bad || !(filed || action === "draft" || action === "hook") || (filed && !folder))
410
+ return usage(args);
411
+ const url = process.env.GHOST_URL;
412
+ const key = process.env.GHOST_ADMIN_API_KEY;
413
+ if (!url || !key) {
414
+ console.error("Set GHOST_URL (the site's address) and GHOST_ADMIN_API_KEY (Ghost Admin, Integrations, a custom integration's Admin API key).");
415
+ return 2;
416
+ }
417
+ const { Ghost, pull, push } = await import("./ghost.js");
418
+ const client = new Ghost({ url, key });
419
+ if (action === "draft" || action === "hook") {
420
+ const { claudeDrafter, draftAll, hookServer, registerWebhooks } = await import("./hook.js");
421
+ const model = process.env.OBS_MODEL;
422
+ const drafter = claudeDrafter(model ? { model } : {});
423
+ if (action === "draft") {
424
+ const opts = { dryRun: args.dryRun, onLine: printLine };
425
+ if (args.slug !== undefined)
426
+ opts.slug = args.slug;
427
+ if (args.limit !== undefined)
428
+ opts.limit = args.limit;
429
+ return tally(await draftAll(client, drafter, opts));
430
+ }
431
+ const secret = process.env.GHOST_WEBHOOK_SECRET;
432
+ if (!secret) {
433
+ console.error("Set GHOST_WEBHOOK_SECRET: any long random text. Ghost signs each webhook with it.");
434
+ return 2;
435
+ }
436
+ const target = process.env.OBS_HOOK_URL;
437
+ if (target)
438
+ for (const line of await registerWebhooks(client, `${target.replace(/\/+$/u, "")}/ghost`, secret))
439
+ console.log(line);
440
+ const port = args.port ?? (Number(process.env.PORT) || 8080);
441
+ await new Promise((done) => hookServer({ ghost: client, secret, drafter }).listen(port, done));
442
+ console.log(`openbriefing ghost hook is listening on ${port}`);
443
+ return new Promise(() => { });
444
+ }
445
+ const opts = { dryRun: args.dryRun };
446
+ if (args.slug !== undefined)
447
+ opts.slug = args.slug;
448
+ const lines = action === "pull" ? await pull(client, folder, args.slug) : await push(client, folder, opts);
449
+ for (const l of lines)
450
+ printLine(l);
451
+ return tally(lines);
452
+ }
306
453
  async function main(argv) {
307
454
  const args = parseArgs(argv);
308
455
  if (args.command === "check")
309
456
  return check(args);
457
+ if (args.command === "tags")
458
+ return tags(args);
459
+ if (args.command === "ghost")
460
+ return ghost(args);
310
461
  if (args.command === "version") {
311
462
  console.log(VERSION);
312
463
  return 0;
@@ -0,0 +1,67 @@
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
+ /** "public", or who may read it. */
11
+ visibility?: string;
12
+ }
13
+ export type FetchLike = (url: string, init: {
14
+ method: string;
15
+ headers: Record<string, string>;
16
+ body?: string;
17
+ }) => Promise<{
18
+ ok: boolean;
19
+ status: number;
20
+ json(): Promise<unknown>;
21
+ text(): Promise<string>;
22
+ }>;
23
+ export interface GhostConfig {
24
+ /** The site's address, such as https://example.com. */
25
+ url: string;
26
+ /** An Admin API key, "id:secret". */
27
+ key: string;
28
+ fetch?: FetchLike;
29
+ now?: () => number;
30
+ }
31
+ /** A short-lived token for Ghost's Admin API, signed with the key's secret. */
32
+ export declare function ghostToken(key: string, nowMs?: number): string;
33
+ export declare class Ghost {
34
+ private readonly config;
35
+ private readonly base;
36
+ private readonly fetcher;
37
+ constructor(config: GhostConfig);
38
+ private call;
39
+ /** Every published post, with its plain text. */
40
+ posts(slug?: string): Promise<GhostPost[]>;
41
+ /** Adds a webhook to the integration this key belongs to. */
42
+ addWebhook(event: string, target: string, secret: string): Promise<void>;
43
+ /** Writes a post's Code injection header and nothing else. `updated_at` is Ghost's guard against overwriting a newer edit. */
44
+ setHead(post: GhostPost, head: string): Promise<void>;
45
+ }
46
+ /** The brief a post's Code injection header carries now, when it has usable tags. */
47
+ export declare function briefInHead(head: string, url: string): ObsBrief | undefined;
48
+ /**
49
+ * The header with the new block in place. Tags written by hand outside the
50
+ * markers are taken out first, so the page never carries two sets. Returns
51
+ * undefined when hand-written tags remain that this cannot safely move.
52
+ */
53
+ export declare function headWith(head: string, block: string): string | undefined;
54
+ /** Ghost's slug shape. Anything else is never used as a file name or in a query. */
55
+ export declare const SLUG: RegExp;
56
+ export interface Line {
57
+ slug: string;
58
+ state: "written" | "would write" | "unchanged" | "draft" | "skipped" | "pulled" | "kept" | "error";
59
+ note?: string;
60
+ }
61
+ /** Writes one file per published post into `folder`. A file that already exists is never touched. */
62
+ export declare function pull(ghost: Ghost, folder: string, slug?: string): Promise<Line[]>;
63
+ /** Writes each approved file's tags into its post. A draft is never written. */
64
+ export declare function push(ghost: Ghost, folder: string, options?: {
65
+ slug?: string;
66
+ dryRun?: boolean;
67
+ }): Promise<Line[]>;
package/dist/ghost.js ADDED
@@ -0,0 +1,186 @@
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
+ /** Adds a webhook to the integration this key belongs to. */
73
+ async addWebhook(event, target, secret) {
74
+ await this.call("POST", "/webhooks/", { webhooks: [{ event, target_url: target, secret, name: `Open Briefing: ${event}` }] });
75
+ }
76
+ /** Writes a post's Code injection header and nothing else. `updated_at` is Ghost's guard against overwriting a newer edit. */
77
+ async setHead(post, head) {
78
+ await this.call("PUT", `/posts/${post.id}/`, { posts: [{ codeinjection_head: head, updated_at: post.updated_at }] });
79
+ }
80
+ }
81
+ const META_OBS = /[ \t]*<meta\s+(?:property|name)\s*=\s*"obs:[^"]*"\s+content\s*=\s*"[^"]*"\s*\/?>[ \t]*\r?\n?/giu;
82
+ /** The brief a post's Code injection header carries now, when it has usable tags. */
83
+ export function briefInHead(head, url) {
84
+ if (!/obs:/u.test(head))
85
+ return undefined;
86
+ return parseObs(new JSDOM(`<!doctype html><html><head>${head}</head><body></body></html>`, { url }).window.document, url).brief;
87
+ }
88
+ /**
89
+ * The header with the new block in place. Tags written by hand outside the
90
+ * markers are taken out first, so the page never carries two sets. Returns
91
+ * undefined when hand-written tags remain that this cannot safely move.
92
+ */
93
+ export function headWith(head, block) {
94
+ const marked = readMarked(head);
95
+ if (marked !== undefined) {
96
+ const outside = head.replace(marked, "");
97
+ if (/obs:(?!start|end)/u.test(outside.replace(META_OBS, "")))
98
+ return undefined;
99
+ return replaceMarked(head.replace(marked, "\u0000").replace(META_OBS, "").replace("\u0000", () => marked), block);
100
+ }
101
+ const cleaned = head.replace(META_OBS, "");
102
+ if (/obs:/u.test(cleaned))
103
+ return undefined;
104
+ return replaceMarked(cleaned, block);
105
+ }
106
+ /** Ghost's slug shape. Anything else is never used as a file name or in a query. */
107
+ export const SLUG = /^[a-z0-9][a-z0-9_-]*$/iu;
108
+ const briefingFiles = (folder) => readdirSync(folder)
109
+ .filter((f) => /\.(md|markdown|ya?ml)$/iu.test(f))
110
+ .sort();
111
+ /** Writes one file per published post into `folder`. A file that already exists is never touched. */
112
+ export async function pull(ghost, folder, slug) {
113
+ mkdirSync(folder, { recursive: true });
114
+ const lines = [];
115
+ if (slug !== undefined && !SLUG.test(slug))
116
+ return [{ slug, state: "error", note: "not a post slug" }];
117
+ for (const post of await ghost.posts(slug)) {
118
+ if (typeof post.slug !== "string" || !SLUG.test(post.slug)) {
119
+ lines.push({ slug: String(post.slug), state: "error", note: "the post's slug is not a plain slug, so no file is written" });
120
+ continue;
121
+ }
122
+ const path = join(folder, `${post.slug}.md`);
123
+ if (existsSync(path)) {
124
+ lines.push({ slug: post.slug, state: "kept", note: "the file is already here" });
125
+ continue;
126
+ }
127
+ // Tags already live on the post were approved by whoever put them there.
128
+ const live = briefInHead(post.codeinjection_head ?? "", post.url);
129
+ const obs = live ? briefingFields({ ...live, url: live.url ?? post.url }, "approved") : { url: post.url, status: "draft" };
130
+ writeFileSync(path, postFile({ slug: post.slug, title: post.title, obs }, post.plaintext ?? ""));
131
+ lines.push({ slug: post.slug, state: "pulled", note: live ? "with the tags the post already carries, as approved" : "a draft to fill in" });
132
+ }
133
+ return lines;
134
+ }
135
+ /** Writes each approved file's tags into its post. A draft is never written. */
136
+ export async function push(ghost, folder, options = {}) {
137
+ const lines = [];
138
+ const files = briefingFiles(folder).filter((f) => options.slug === undefined || f.replace(/\.[^.]+$/u, "") === options.slug);
139
+ if (files.length === 0)
140
+ return lines;
141
+ const only = files.length === 1 ? files[0].replace(/\.[^.]+$/u, "") : undefined;
142
+ const posts = new Map((await ghost.posts(only !== undefined && SLUG.test(only) ? only : undefined)).map((p) => [p.slug, p]));
143
+ for (const file of files) {
144
+ const briefing = parseBriefing(readFileSync(join(folder, file), "utf8"));
145
+ const slug = briefing.slug ?? file.replace(/\.[^.]+$/u, "");
146
+ if (briefing.status !== "approved") {
147
+ lines.push({ slug, state: "draft", note: "not approved, so not written" });
148
+ continue;
149
+ }
150
+ if (!briefing.brief) {
151
+ lines.push({ slug, state: "error", note: briefing.errors.join("; ") });
152
+ continue;
153
+ }
154
+ const post = posts.get(slug);
155
+ if (!post) {
156
+ lines.push({ slug, state: "error", note: "no published post has this slug" });
157
+ continue;
158
+ }
159
+ const head = post.codeinjection_head ?? "";
160
+ const marked = readMarked(head);
161
+ const live = marked === undefined ? undefined : briefInHead(marked, post.url);
162
+ const next = headWith(head, markTags(renderTags(briefing.brief)));
163
+ if (next === undefined) {
164
+ 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" });
165
+ continue;
166
+ }
167
+ const outside = marked === undefined ? head : head.replace(marked, "");
168
+ const stray = outside.replace(META_OBS, "") !== outside;
169
+ if (live && !stray && fingerprint(live) === fingerprint(briefing.brief)) {
170
+ lines.push({ slug, state: "unchanged" });
171
+ continue;
172
+ }
173
+ if (options.dryRun) {
174
+ lines.push({ slug, state: "would write" });
175
+ continue;
176
+ }
177
+ try {
178
+ await ghost.setHead(post, next);
179
+ lines.push({ slug, state: "written" });
180
+ }
181
+ catch (err) {
182
+ lines.push({ slug, state: "error", note: err instanceof Error ? err.message : String(err) });
183
+ }
184
+ }
185
+ return lines;
186
+ }
package/dist/hook.d.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { type Server } from "node:http";
2
+ import { type Ghost, type GhostPost, type Line } from "./ghost.js";
3
+ import { type ObsBrief } from "./obs.js";
4
+ /** Writes a briefing file (YAML) for a post. */
5
+ export type Drafter = (post: {
6
+ title: string;
7
+ url: string;
8
+ text: string;
9
+ }) => Promise<string>;
10
+ /** A post shorter than this has nothing for a 200-word summary to shorten. */
11
+ export declare const MIN_WORDS = 200;
12
+ /** The drafting prompt, the kit's `prompt.md`. */
13
+ export declare function draftingPrompt(): string;
14
+ /** A drafter that asks Claude. The key comes from ANTHROPIC_API_KEY. */
15
+ export declare function claudeDrafter(options?: {
16
+ model?: string;
17
+ }): Drafter;
18
+ /** The marked block for a brief the hook wrote, with its stamp. */
19
+ export declare function autoBlock(brief: ObsBrief, url: string): string;
20
+ export type Decision = {
21
+ act: "draft" | "redraft";
22
+ } | {
23
+ act: "leave";
24
+ why: string;
25
+ };
26
+ /** What the hook does for a post, from the post alone. */
27
+ export declare function decide(post: GhostPost): Decision;
28
+ /** A drafted brief for a post, with any highlight the post does not contain taken out. */
29
+ export declare function draftBrief(post: GhostPost, drafter: Drafter): Promise<ObsBrief>;
30
+ /** Drafts and writes one post's tags, when the post is the hook's to write. */
31
+ export declare function autoTag(ghost: Ghost, slug: string, drafter: Drafter, options?: {
32
+ dryRun?: boolean;
33
+ }): Promise<Line>;
34
+ /** Drafts tags for every published post that has none. One post at a time. */
35
+ export declare function draftAll(ghost: Ghost, drafter: Drafter, options?: {
36
+ slug?: string;
37
+ limit?: number;
38
+ dryRun?: boolean;
39
+ onLine?: (line: Line) => void;
40
+ }): Promise<Line[]>;
41
+ /** Ghost signs a webhook as `sha256=<hex HMAC of body + t>, t=<ms>`. */
42
+ export declare function verifySignature(body: string, header: string | undefined, secret: string, now?: number): boolean;
43
+ /** The slug a webhook is about. The payload is untrusted; the post itself is read from Ghost. */
44
+ export declare function slugOf(body: string): string | undefined;
45
+ export declare const HOOK_EVENTS: readonly ["post.published", "post.published.edited"];
46
+ /** Registers the hook's webhooks on the integration the Admin API key belongs to. Safe to repeat. */
47
+ export declare function registerWebhooks(ghost: Ghost, target: string, secret: string): Promise<string[]>;
48
+ export interface HookOptions {
49
+ ghost: Ghost;
50
+ secret: string;
51
+ drafter: Drafter;
52
+ log?: (message: string) => void;
53
+ now?: () => number;
54
+ }
55
+ /**
56
+ * An HTTP server for Ghost's webhooks: POST /ghost and GET /healthz. Ghost
57
+ * gives up after two seconds, so the hook answers 202 and then does the work.
58
+ */
59
+ export declare function hookServer(options: HookOptions): Server & {
60
+ idle(): Promise<void>;
61
+ };
package/dist/hook.js ADDED
@@ -0,0 +1,272 @@
1
+ /**
2
+ * `openbriefing ghost hook` and `ghost draft`: tags written for a post when it
3
+ * is published, with no step for the author.
4
+ *
5
+ * The hook drafts the summary with a model, checks every highlight against the
6
+ * post's text, and writes the marked block into the post's Code injection
7
+ * header. The block carries a stamp, `<!-- obs:auto <fingerprint> -->`. While
8
+ * the tags still match the stamp the block is the hook's; once the author
9
+ * edits them it is theirs, and the hook never writes to that post again.
10
+ */
11
+ import { createHmac, timingSafeEqual } from "node:crypto";
12
+ import { existsSync, readFileSync } from "node:fs";
13
+ import { createServer } from "node:http";
14
+ import { checkHighlights, MARK_END, MARK_START, parseBriefing, readMarked, renderTags } from "./briefing.js";
15
+ import { briefInHead, headWith, SLUG } from "./ghost.js";
16
+ import { countWords, fingerprint } from "./obs.js";
17
+ /** A post shorter than this has nothing for a 200-word summary to shorten. */
18
+ export const MIN_WORDS = 200;
19
+ const STAMP = /<!--\s*obs:auto\s+([0-9a-f]{16})\s*-->/u;
20
+ const MAX_AGE_MS = 5 * 60 * 1000;
21
+ const MAX_BODY_BYTES = 5 * 1024 * 1024;
22
+ /** The drafting prompt, the kit's `prompt.md`. */
23
+ export function draftingPrompt() {
24
+ // Packed beside dist/ when published; at the repository's root when run from source.
25
+ for (const rel of ["../kit/prompt.md", "../../../kit/prompt.md"]) {
26
+ const path = new URL(rel, import.meta.url);
27
+ if (existsSync(path))
28
+ return readFileSync(path, "utf8");
29
+ }
30
+ throw new Error("The drafting prompt (kit/prompt.md) is missing from this install");
31
+ }
32
+ /** A drafter that asks Claude. The key comes from ANTHROPIC_API_KEY. */
33
+ export function claudeDrafter(options = {}) {
34
+ const system = draftingPrompt();
35
+ return async (post) => {
36
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
37
+ // A key that is not tied to one workspace needs the workspace named.
38
+ const workspace = process.env.ANTHROPIC_WORKSPACE_ID;
39
+ const client = new Anthropic(workspace ? { defaultHeaders: { "anthropic-workspace-id": workspace } } : {});
40
+ const response = await client.beta.messages.create({
41
+ model: options.model ?? "claude-opus-5-5",
42
+ max_tokens: 16000,
43
+ thinking: { type: "adaptive" },
44
+ betas: ["server-side-fallback-2026-07-01"],
45
+ fallbacks: "default",
46
+ system,
47
+ messages: [{ role: "user", content: `Address: ${post.url}\nTitle: ${post.title}\n\n${post.text}` }],
48
+ });
49
+ if (response.stop_reason === "refusal")
50
+ throw new Error("the model declined to draft this post");
51
+ if (response.stop_reason === "max_tokens")
52
+ throw new Error("the draft was cut off");
53
+ return response.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("");
54
+ };
55
+ }
56
+ /** The marked block for a brief the hook wrote, with its stamp. */
57
+ export function autoBlock(brief, url) {
58
+ const tags = renderTags(brief);
59
+ // The stamp is taken from the tags as a reader parses them, so it holds for exactly what is on the page.
60
+ const read = briefInHead(tags, url);
61
+ return [MARK_START, `<!-- obs:auto ${fingerprint(read ?? brief)} -->`, tags, MARK_END].join("\n");
62
+ }
63
+ /** What the hook does for a post, from the post alone. */
64
+ export function decide(post) {
65
+ if (post.visibility !== undefined && post.visibility !== "public")
66
+ return { act: "leave", why: "the post is not public" };
67
+ const text = post.plaintext ?? "";
68
+ if (countWords(text) < MIN_WORDS)
69
+ return { act: "leave", why: `the post is under ${MIN_WORDS} words` };
70
+ const head = post.codeinjection_head ?? "";
71
+ if (!/obs:/u.test(head))
72
+ return { act: "draft" };
73
+ const marked = readMarked(head);
74
+ const stamp = marked === undefined ? undefined : STAMP.exec(marked)?.[1];
75
+ const live = marked === undefined ? undefined : briefInHead(marked, post.url);
76
+ if (stamp === undefined || !live || fingerprint(live) !== stamp || head.replace(marked, "").includes("obs:"))
77
+ return { act: "leave", why: "the author's own tags" };
78
+ // Still the hook's block. It is redone only when the post changed under a quote.
79
+ checkHighlights(live, text, []);
80
+ if (live.highlights.some((h) => h.inContent === false))
81
+ return { act: "redraft" };
82
+ return { act: "leave", why: "the tags are current" };
83
+ }
84
+ const unfence = (text) => text
85
+ .trim()
86
+ .replace(/^```[a-z]*\r?\n/iu, "")
87
+ .replace(/\r?\n```$/u, "");
88
+ /** A drafted brief for a post, with any highlight the post does not contain taken out. */
89
+ export async function draftBrief(post, drafter) {
90
+ const text = post.plaintext ?? "";
91
+ let problem = "";
92
+ for (let attempt = 0; attempt < 2; attempt++) {
93
+ const briefing = parseBriefing(unfence(await drafter({ title: post.title, url: post.url, text })));
94
+ if (!briefing.brief) {
95
+ problem = briefing.errors.join("; ") || "no summary";
96
+ continue;
97
+ }
98
+ const brief = briefing.brief;
99
+ checkHighlights(brief, text, []);
100
+ brief.highlights = brief.highlights.filter((h) => h.inContent !== false);
101
+ // The address is the post's, whatever the draft said.
102
+ const withUrl = parseBriefing(JSON.stringify({ ...draftFields(brief), url: post.url })).brief;
103
+ return withUrl ?? brief;
104
+ }
105
+ throw new Error(`the draft was not usable: ${problem}`);
106
+ }
107
+ function draftFields(brief) {
108
+ const out = {};
109
+ if (brief.title)
110
+ out.title = brief.title;
111
+ if (brief.headline)
112
+ out.headline = brief.headline;
113
+ for (const s of brief.summaries)
114
+ out[s.from === "inferred" ? "summary" : `summary_${s.tier}`] = s.text;
115
+ if (brief.keep.length > 0)
116
+ out.keep = brief.keep;
117
+ if (brief.highlights.length > 0)
118
+ out.highlights = brief.highlights.map((h) => (h.label ? { text: h.text, label: h.label } : { text: h.text }));
119
+ return out;
120
+ }
121
+ /** Drafts and writes one post's tags, when the post is the hook's to write. */
122
+ export async function autoTag(ghost, slug, drafter, options = {}) {
123
+ if (!SLUG.test(slug))
124
+ return { slug, state: "error", note: "not a post slug" };
125
+ try {
126
+ let post = (await ghost.posts(slug))[0];
127
+ if (!post)
128
+ return { slug, state: "skipped", note: "no published post has this slug" };
129
+ const decision = decide(post);
130
+ if (decision.act === "leave")
131
+ return { slug, state: "kept", note: decision.why };
132
+ if (options.dryRun)
133
+ return { slug, state: "would write" };
134
+ const brief = await draftBrief(post, drafter);
135
+ for (let attempt = 0;; attempt++) {
136
+ const next = headWith(post.codeinjection_head ?? "", autoBlock(brief, post.url));
137
+ if (next === undefined)
138
+ return { slug, state: "kept", note: "the author's own tags" };
139
+ try {
140
+ await ghost.setHead(post, next);
141
+ return { slug, state: "written", note: decision.act === "redraft" ? "redone, the post changed under a quote" : "drafted" };
142
+ }
143
+ catch (err) {
144
+ // The post was saved while the draft was being written: read it again, once.
145
+ if (attempt > 0 || !(err instanceof Error) || !err.message.includes("409"))
146
+ throw err;
147
+ const fresh = (await ghost.posts(slug))[0];
148
+ if (!fresh || decide(fresh).act === "leave")
149
+ return { slug, state: "kept", note: "the post changed while the draft was written" };
150
+ post = fresh;
151
+ }
152
+ }
153
+ }
154
+ catch (err) {
155
+ return { slug, state: "error", note: err instanceof Error ? err.message : String(err) };
156
+ }
157
+ }
158
+ /** Drafts tags for every published post that has none. One post at a time. */
159
+ export async function draftAll(ghost, drafter, options = {}) {
160
+ const lines = [];
161
+ let written = 0;
162
+ for (const post of await ghost.posts(options.slug)) {
163
+ if (options.limit !== undefined && written >= options.limit)
164
+ break;
165
+ const opts = options.dryRun === undefined ? {} : { dryRun: options.dryRun };
166
+ const line = await autoTag(ghost, post.slug, drafter, opts);
167
+ if (line.state === "written" || line.state === "would write")
168
+ written++;
169
+ lines.push(line);
170
+ options.onLine?.(line);
171
+ }
172
+ return lines;
173
+ }
174
+ /** Ghost signs a webhook as `sha256=<hex HMAC of body + t>, t=<ms>`. */
175
+ export function verifySignature(body, header, secret, now = Date.now()) {
176
+ const match = /^sha256=([0-9a-f]{64}), t=(\d+)$/u.exec(header ?? "");
177
+ if (!match || secret === "")
178
+ return false;
179
+ const [, sig, ts] = match;
180
+ if (Math.abs(now - Number(ts)) > MAX_AGE_MS)
181
+ return false;
182
+ const expected = createHmac("sha256", secret).update(`${body}${ts}`).digest();
183
+ return timingSafeEqual(Buffer.from(sig, "hex"), expected);
184
+ }
185
+ /** The slug a webhook is about. The payload is untrusted; the post itself is read from Ghost. */
186
+ export function slugOf(body) {
187
+ try {
188
+ const slug = JSON.parse(body).post?.current?.slug;
189
+ return typeof slug === "string" && SLUG.test(slug) ? slug : undefined;
190
+ }
191
+ catch {
192
+ return undefined;
193
+ }
194
+ }
195
+ export const HOOK_EVENTS = ["post.published", "post.published.edited"];
196
+ /** Registers the hook's webhooks on the integration the Admin API key belongs to. Safe to repeat. */
197
+ export async function registerWebhooks(ghost, target, secret) {
198
+ const out = [];
199
+ for (const event of HOOK_EVENTS) {
200
+ try {
201
+ await ghost.addWebhook(event, target, secret);
202
+ out.push(`${event}: registered`);
203
+ }
204
+ catch (err) {
205
+ const message = err instanceof Error ? err.message : String(err);
206
+ // Ghost answers 422 when this address already has the event.
207
+ out.push(message.includes("422") ? `${event}: already registered` : `${event}: ${message}`);
208
+ }
209
+ }
210
+ return out;
211
+ }
212
+ /**
213
+ * An HTTP server for Ghost's webhooks: POST /ghost and GET /healthz. Ghost
214
+ * gives up after two seconds, so the hook answers 202 and then does the work.
215
+ */
216
+ export function hookServer(options) {
217
+ const log = options.log ?? ((m) => console.log(m));
218
+ // One post at a time per slug: a publish and the edit that follows it never race.
219
+ const busy = new Map();
220
+ const run = (slug) => {
221
+ const next = (busy.get(slug) ?? Promise.resolve())
222
+ .then(() => autoTag(options.ghost, slug, options.drafter))
223
+ .then((line) => log(`${line.state} ${slug}${line.note ? ` (${line.note})` : ""}`))
224
+ .catch((err) => log(`error ${slug} (${err instanceof Error ? err.message : String(err)})`))
225
+ .finally(() => {
226
+ if (busy.get(slug) === next)
227
+ busy.delete(slug);
228
+ });
229
+ busy.set(slug, next);
230
+ };
231
+ const server = createServer((req, res) => {
232
+ if (req.method === "GET" && req.url === "/healthz") {
233
+ res.writeHead(200, { "content-type": "text/plain" }).end("ok");
234
+ return;
235
+ }
236
+ if (req.method !== "POST" || req.url !== "/ghost") {
237
+ res.writeHead(404).end();
238
+ return;
239
+ }
240
+ const chunks = [];
241
+ let size = 0;
242
+ req.on("data", (c) => {
243
+ size += c.length;
244
+ if (size > MAX_BODY_BYTES) {
245
+ res.writeHead(413).end();
246
+ req.destroy();
247
+ return;
248
+ }
249
+ chunks.push(c);
250
+ });
251
+ req.on("end", () => {
252
+ if (size > MAX_BODY_BYTES)
253
+ return;
254
+ const body = Buffer.concat(chunks).toString("utf8");
255
+ const header = req.headers["x-ghost-signature"];
256
+ if (!verifySignature(body, typeof header === "string" ? header : undefined, options.secret, options.now?.() ?? Date.now())) {
257
+ res.writeHead(401).end();
258
+ return;
259
+ }
260
+ const slug = slugOf(body);
261
+ if (slug === undefined) {
262
+ res.writeHead(400).end();
263
+ return;
264
+ }
265
+ res.writeHead(202).end();
266
+ run(slug);
267
+ });
268
+ });
269
+ server.headersTimeout = 10_000;
270
+ server.requestTimeout = 15_000;
271
+ return Object.assign(server, { idle: async () => void (await Promise.all([...busy.values()])) });
272
+ }
package/kit/prompt.md ADDED
@@ -0,0 +1,40 @@
1
+ You are drafting an author's own summary of their post, in the Open Briefing Standard's briefing-file format. Tools that brief or read posts aloud will use these words in place of a summary they would otherwise write themselves, and they will credit the author. So the draft must sound like the author and say only what the post says.
2
+
3
+ Return one YAML briefing file and nothing else: no preface, no code fence, no notes after it.
4
+
5
+ ```yaml
6
+ url: <the post's address, if you were given it; leave the key out if not>
7
+ headline: <one or two sentences, 40 words or fewer>
8
+ summary: |
9
+ <200 to 350 words, in two to four paragraphs, with a blank line between paragraphs>
10
+ keep:
11
+ - <the one sentence every shorter version must get across>
12
+ highlights:
13
+ - text: <a passage copied from the post, word for word>
14
+ label: <what it is about, 6 words or fewer>
15
+ status: draft
16
+ ```
17
+
18
+ How to write each part:
19
+
20
+ **summary.** 200 to 350 words. Use the author's own sentences where they serve, and their voice everywhere: first person if the post is first person, their vocabulary, their level of formality. Say what the post argues or reports and the reasons or evidence it gives, in the order a listener needs. The first two sentences must stand on their own, because a tool that needs something shorter will often use just those. The summary must make sense to someone who has not seen the title.
21
+
22
+ **headline.** A line someone might hear before deciding whether to listen further. It carries the post's main point, not a teaser for it.
23
+
24
+ **keep.** Up to three lines, most important first. The first is the post's core message in one sentence. Add a second or third only for something a shorter version must not lose, such as a number, a caveat or a name. Each line should also appear in the summary, in the same words.
25
+
26
+ **highlights.** Up to four passages the author might be glad to see quoted on their own. Copy each one from the post exactly, word for word and with its punctuation, 60 words or fewer. Do not join passages from different places, trim words from the middle, or fix the author's wording. If a passage ends in the post with a comma before a closing quotation mark, end your copy before the comma. If nothing in the post reads well alone, leave `highlights` out.
27
+
28
+ **title.** Leave it out, unless the post's own title is longer than 12 words. Then add `title:` with a shorter one.
29
+
30
+ Rules for every line:
31
+
32
+ - Plain text that works both read and heard. No Markdown, no HTML, no emoji, no bullet lists inside a value.
33
+ - No web addresses in any line you write (the `url:` key is the one exception), and no "click here", "see below", "in this post" or "pictured above". No reference to images, tables or layout.
34
+ - Spell out an abbreviation the first time, unless it is normally said as a word.
35
+ - In the lines you write yourself, prefer a single quotation mark to a double one wherever either reads as well. The words end up inside an HTML attribute, where a double quotation mark has to be escaped. A highlight keeps the post's own marks.
36
+ - Say nothing the post does not support. Do not add background, opinion, praise or a conclusion the author did not draw. If the post is unclear on a point, leave the point out.
37
+ - Write in the post's language.
38
+ - Always end the file with `status: draft`. Only the author approves it.
39
+
40
+ The post follows.
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.5.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"
@@ -38,6 +42,7 @@
38
42
  "files": [
39
43
  "dist",
40
44
  "fixtures",
45
+ "kit",
41
46
  "README.md"
42
47
  ],
43
48
  "engines": {
@@ -49,10 +54,12 @@
49
54
  "test": "vitest run",
50
55
  "fixtures": "node ../../scripts/make-fixtures.mjs",
51
56
  "expected": "tsx scripts/write-expected.ts",
52
- "prepack": "rm -rf fixtures && cp -R ../../spec/fixtures fixtures && npm run build"
57
+ "prepack": "rm -rf fixtures kit && cp -R ../../spec/fixtures fixtures && mkdir kit && cp ../../kit/prompt.md kit/prompt.md && npm run build"
53
58
  },
54
59
  "dependencies": {
55
- "jsdom": "^26.1.0"
60
+ "@anthropic-ai/sdk": "^1.8.0",
61
+ "jsdom": "^26.1.0",
62
+ "yaml": "^2.9.1"
56
63
  },
57
64
  "devDependencies": {
58
65
  "@types/jsdom": "^21.1.7",