openbriefing 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,17 +2,65 @@
2
2
 
3
3
  This package is the reference parser and checker for the [Open Briefing Standard (OBS)](https://openbriefingstandard.org). OBS is how a publisher says how their content should be summarized and presented when software consumes it.
4
4
 
5
- **Version 0.2.** It contains the parser and `openbriefing check`, and reads spec version 1.1, which adds `obs:highlight`: passages the publisher marks, word for word from the content, for quoting on their own with credit. `check` lists each highlight with its word count and label, and warns when one is not found in the page's text. Later versions add `generate`, `preview`, `backfill`, `review` and a Ghost integration.
5
+ **Version 0.3.** It contains the parser and `openbriefing check`, and reads spec version 1 (release candidate). One tag, `obs:summary`, is enough: `obs:modified` is optional, and the parser reports where a modified time came from. `check` reads a page, a feed or a whole sitemap from the web.
6
6
 
7
- ## Check a page or a feed
7
+ ## Check a page, a feed or a site
8
8
 
9
9
  ```bash
10
+ npx openbriefing check https://example.com/a-post/
10
11
  npx openbriefing check ./post.html
11
- npx openbriefing check ./feed.xml
12
- curl -s https://example.com/post/ | npx openbriefing check - --url https://example.com/post/
12
+ npx openbriefing check https://example.com/rss/ --feed --pages
13
+ npx openbriefing check https://example.com/sitemap.xml --sitemap
13
14
  ```
14
15
 
15
- `check` exits 1 when the tags are unusable, so a site's CI can gate on it. Add `--json` to get the parsed result as JSON.
16
+ - **A page:** prints each form with its word count, the keep lines, the highlights (and whether each is in the page's text), the modified time and its source, and a fingerprint.
17
+ - **`--feed`:** checks every item. With `--pages` it also fetches each item's page and says which of the two stands.
18
+ - **`--sitemap`:** one line per page (`ok`, `ok, 2 warnings`, `no tags`, `not usable`) and a total. `--limit 20` checks the first 20.
19
+
20
+ `check` exits 1 when tags are present but unusable, so a site's CI can gate on it. A page with no tags is not an error in a sitemap. Add `--json` for the parsed result. For a file with no canonical link, `--url` gives the page's address; `-` reads standard input.
21
+
22
+ Fetching is bounded: `http` and `https` only, 10 seconds, 5 MB, 5 redirects, and it names itself `openbriefing-check/<version>`.
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.
16
64
 
17
65
  ## Use the parser
18
66
 
@@ -22,7 +70,8 @@ import { parseObs } from "openbriefing";
22
70
 
23
71
  const dom = new JSDOM(html, { url });
24
72
  const { brief, errors, warnings } = parseObs(dom.window.document, url);
25
- // brief.summaries, brief.keep, brief.highlights (each { text, words, label?, inContent? }), …
73
+ // brief.summaries, brief.keep, brief.highlights (each { text, words, label?, inContent? }),
74
+ // brief.modified (optional) and brief.modifiedFrom ("obs" | "article" | "feed" | "none"), …
26
75
  ```
27
76
 
28
77
  | Export | What it does |
@@ -30,10 +79,11 @@ const { brief, errors, warnings } = parseObs(dom.window.document, url);
30
79
  | `parseObs(document, pageUrl)` | Reads a page's `obs:` meta tags |
31
80
  | `parseFeedObs(itemElement, itemUrl?)` | Reads one RSS `<item>` or Atom `<entry>` |
32
81
  | `validateObs(object)` | Checks an object already in the `ObsBrief` shape |
33
- | `mergeObs(page, feed)` | Applies the feed-against-page rule: the later `obs:modified` stands, and the page wins a tie |
82
+ | `mergeObs(page, feed)` | Applies the feed-against-page rule: the later effective modified time stands; the page wins a tie, and when either has no time |
83
+ | `fingerprint(brief)` | A stable 16-character hash of every text value and setting. Compare two to tell whether a brief changed, with or without a modified time |
34
84
  | `tierFor(words)`, `SIZES`, `normalizeUrl`, `looseText` | Helpers that use the spec's numbers and its loose text comparison |
35
85
 
36
- The parser has no runtime dependencies. It takes any DOM `Document` (jsdom, linkedom, or a browser's `DOMParser`), makes no network requests, and never throws.
86
+ The parser has no runtime dependencies. It takes any DOM `Document` (jsdom, linkedom, or a browser's `DOMParser`), makes no network requests, and never throws. The bounded fetch that `check` uses is exported separately as `openbriefing/fetch`.
37
87
 
38
88
  The test fixtures ship with the package in `openbriefing/fixtures/`, with `expected.json`.
39
89
 
@@ -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
+ }