openbriefing 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +123 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/obs.d.ts +86 -0
- package/dist/obs.js +449 -0
- package/fixtures/bad-modified.html +13 -0
- package/fixtures/band-120.html +13 -0
- package/fixtures/band-121.html +13 -0
- package/fixtures/band-500.html +13 -0
- package/fixtures/band-501.html +13 -0
- package/fixtures/body-tags.html +13 -0
- package/fixtures/derive-bad.html +15 -0
- package/fixtures/derive-verbatim.html +15 -0
- package/fixtures/duplicate-headline.html +15 -0
- package/fixtures/expected.json +804 -0
- package/fixtures/feed-atom.xml +16 -0
- package/fixtures/feed-rss.xml +26 -0
- package/fixtures/feed-vs-page.html +13 -0
- package/fixtures/injection.html +13 -0
- package/fixtures/keep-limits.html +18 -0
- package/fixtures/lang-fallback.html +13 -0
- package/fixtures/markup-in-text.html +13 -0
- package/fixtures/missing-modified.html +12 -0
- package/fixtures/missing-summary.html +13 -0
- package/fixtures/name-attribute.html +13 -0
- package/fixtures/newer-version.html +14 -0
- package/fixtures/no-tags.html +12 -0
- package/fixtures/over-sizes.html +15 -0
- package/fixtures/paragraphs.html +13 -0
- package/fixtures/preference-full.html +14 -0
- package/fixtures/tier-collision.html +14 -0
- package/fixtures/tiered-only.html +14 -0
- package/fixtures/unknown-and-reserved.html +15 -0
- package/fixtures/url-other.html +14 -0
- package/fixtures/url-same.html +14 -0
- package/fixtures/valid-full.html +24 -0
- package/fixtures/valid-minimal.html +13 -0
- package/package.json +49 -4
package/dist/obs.js
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Open Briefing Standard, version 1: reference parser.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained: no runtime imports. It reads a DOM `Document` or `Element`
|
|
5
|
+
* (jsdom on a server, DOMParser in a browser) and never touches the network.
|
|
6
|
+
* Every function here returns a result rather than throwing.
|
|
7
|
+
*
|
|
8
|
+
* Section numbers in comments refer to spec/v1.md.
|
|
9
|
+
*/
|
|
10
|
+
export const OBS_NS = "https://openbriefingstandard.org/ns#";
|
|
11
|
+
/** Sizes from §4, exported so tools show the same numbers. */
|
|
12
|
+
export const SIZES = {
|
|
13
|
+
title: 12,
|
|
14
|
+
headline: 40,
|
|
15
|
+
keep: 40,
|
|
16
|
+
attribution: 12,
|
|
17
|
+
keepMax: 3,
|
|
18
|
+
tiers: {
|
|
19
|
+
short: { target: [40, 80] },
|
|
20
|
+
medium: { target: [200, 350] },
|
|
21
|
+
long: { target: [500, 800] },
|
|
22
|
+
},
|
|
23
|
+
bands: { shortMax: 120, mediumMax: 500 },
|
|
24
|
+
};
|
|
25
|
+
const TIERS = ["short", "medium", "long"];
|
|
26
|
+
/** The tier for a word count, by the bands in §4. */
|
|
27
|
+
export function tierFor(words) {
|
|
28
|
+
if (words <= SIZES.bands.shortMax)
|
|
29
|
+
return "short";
|
|
30
|
+
if (words <= SIZES.bands.mediumMax)
|
|
31
|
+
return "medium";
|
|
32
|
+
return "long";
|
|
33
|
+
}
|
|
34
|
+
/** Words are runs of characters between white space (§4). */
|
|
35
|
+
export function countWords(text) {
|
|
36
|
+
const t = text.trim();
|
|
37
|
+
return t === "" ? 0 : t.split(/\s+/u).length;
|
|
38
|
+
}
|
|
39
|
+
const KNOWN = new Set([
|
|
40
|
+
"obs:modified",
|
|
41
|
+
"obs:summary",
|
|
42
|
+
"obs:summary:short",
|
|
43
|
+
"obs:summary:medium",
|
|
44
|
+
"obs:summary:long",
|
|
45
|
+
"obs:title",
|
|
46
|
+
"obs:headline",
|
|
47
|
+
"obs:keep",
|
|
48
|
+
"obs:derive",
|
|
49
|
+
"obs:derive:translate",
|
|
50
|
+
"obs:derive:combine",
|
|
51
|
+
"obs:attribution",
|
|
52
|
+
"obs:preference",
|
|
53
|
+
"obs:lang",
|
|
54
|
+
"obs:url",
|
|
55
|
+
"obs:version",
|
|
56
|
+
]);
|
|
57
|
+
function put(bag, key, value) {
|
|
58
|
+
const list = bag.get(key);
|
|
59
|
+
if (list)
|
|
60
|
+
list.push(value);
|
|
61
|
+
else
|
|
62
|
+
bag.set(key, [value]);
|
|
63
|
+
}
|
|
64
|
+
function first(bag, key, warnings) {
|
|
65
|
+
const list = bag.get(key);
|
|
66
|
+
if (!list || list.length === 0)
|
|
67
|
+
return undefined;
|
|
68
|
+
if (list.length > 1 && key !== "obs:keep") {
|
|
69
|
+
warnings.push(`${key} appears ${list.length} times; the first is used (§3.1)`);
|
|
70
|
+
}
|
|
71
|
+
return list[0];
|
|
72
|
+
}
|
|
73
|
+
const TAG_RE = /<[^>]*>/gu;
|
|
74
|
+
const URL_RE = /\b(?:https?:\/\/|www\.)\S+/iu;
|
|
75
|
+
/** Plain-text rules of §8: strip markup, warn about URLs, trim. */
|
|
76
|
+
function cleanText(key, raw, warnings) {
|
|
77
|
+
let text = raw;
|
|
78
|
+
if (TAG_RE.test(text)) {
|
|
79
|
+
text = text.replace(TAG_RE, "");
|
|
80
|
+
warnings.push(`${key}: HTML removed; values are plain text (§8)`);
|
|
81
|
+
}
|
|
82
|
+
TAG_RE.lastIndex = 0;
|
|
83
|
+
text = text.replace(/\r\n?/gu, "\n").trim();
|
|
84
|
+
if (text === "")
|
|
85
|
+
return undefined;
|
|
86
|
+
if (URL_RE.test(text))
|
|
87
|
+
warnings.push(`${key}: contains a URL; summaries should not (§8)`);
|
|
88
|
+
return text;
|
|
89
|
+
}
|
|
90
|
+
function enumValue(key, raw, allowed, fallback, warnings) {
|
|
91
|
+
if (raw === undefined)
|
|
92
|
+
return fallback;
|
|
93
|
+
const v = raw.trim().toLowerCase();
|
|
94
|
+
if (allowed.includes(v))
|
|
95
|
+
return v;
|
|
96
|
+
warnings.push(`${key}: "${raw}" is not one of ${allowed.join(", ")}; the default "${fallback}" applies`);
|
|
97
|
+
return fallback;
|
|
98
|
+
}
|
|
99
|
+
/** ISO 8601 date-time, with a time and an offset or Z. */
|
|
100
|
+
const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/u;
|
|
101
|
+
function validModified(v) {
|
|
102
|
+
return ISO_RE.test(v) && !Number.isNaN(Date.parse(v));
|
|
103
|
+
}
|
|
104
|
+
const TRACKING = /^(?:utm_.*|fbclid|gclid)$/iu;
|
|
105
|
+
/** URL comparison of §11: scheme and host case, trailing slash, tracking parameters. */
|
|
106
|
+
export function normalizeUrl(input, base) {
|
|
107
|
+
let u;
|
|
108
|
+
try {
|
|
109
|
+
u = base ? new URL(input, base) : new URL(input);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
if (u.protocol !== "http:" && u.protocol !== "https:")
|
|
115
|
+
return undefined;
|
|
116
|
+
u.hash = "";
|
|
117
|
+
for (const k of [...u.searchParams.keys()])
|
|
118
|
+
if (TRACKING.test(k))
|
|
119
|
+
u.searchParams.delete(k);
|
|
120
|
+
let path = u.pathname;
|
|
121
|
+
if (path.length > 1 && path.endsWith("/"))
|
|
122
|
+
path = path.replace(/\/+$/u, "");
|
|
123
|
+
const query = u.searchParams.toString();
|
|
124
|
+
return `${u.protocol}//${u.host.toLowerCase()}${path}${query ? `?${query}` : ""}`;
|
|
125
|
+
}
|
|
126
|
+
function sizeWarning(key, words, max, warnings) {
|
|
127
|
+
if (words > max)
|
|
128
|
+
warnings.push(`${key}: ${words} words, over its size of ${max} (§4)`);
|
|
129
|
+
}
|
|
130
|
+
function build(bag, ctx, extraWarnings = []) {
|
|
131
|
+
const errors = [];
|
|
132
|
+
const warnings = [...extraWarnings];
|
|
133
|
+
for (const key of bag.keys()) {
|
|
134
|
+
if (KNOWN.has(key))
|
|
135
|
+
continue;
|
|
136
|
+
if (key.startsWith("obs:audio:") || key === "obs:audio")
|
|
137
|
+
continue; // reserved, §14
|
|
138
|
+
warnings.push(`${key} is not an OBS v1 property and is ignored (§3.1)`);
|
|
139
|
+
}
|
|
140
|
+
// Text values, cleaned once.
|
|
141
|
+
const text = (key) => {
|
|
142
|
+
const raw = first(bag, key, warnings);
|
|
143
|
+
return raw === undefined ? undefined : cleanText(key, raw, warnings);
|
|
144
|
+
};
|
|
145
|
+
// obs:version
|
|
146
|
+
let version = 1;
|
|
147
|
+
const rawVersion = first(bag, "obs:version", warnings);
|
|
148
|
+
if (rawVersion !== undefined) {
|
|
149
|
+
const n = Number.parseInt(rawVersion.trim(), 10);
|
|
150
|
+
if (!Number.isInteger(n) || n < 1 || String(n) !== rawVersion.trim()) {
|
|
151
|
+
warnings.push(`obs:version: "${rawVersion}" is not a whole number; 1 is assumed`);
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
version = n;
|
|
155
|
+
if (n > 1)
|
|
156
|
+
warnings.push(`obs:version ${n} is newer than this reader (v1); unknown tags are ignored`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// obs:modified (required)
|
|
160
|
+
const modified = first(bag, "obs:modified", warnings)?.trim();
|
|
161
|
+
if (modified === undefined || modified === "") {
|
|
162
|
+
errors.push("obs:modified is missing; it is required (§3)");
|
|
163
|
+
}
|
|
164
|
+
else if (!validModified(modified)) {
|
|
165
|
+
errors.push(`obs:modified: "${modified}" is not an ISO 8601 date and time (§3)`);
|
|
166
|
+
}
|
|
167
|
+
// Summaries (§4)
|
|
168
|
+
const tiered = new Map();
|
|
169
|
+
for (const tier of TIERS) {
|
|
170
|
+
const t = text(`obs:summary:${tier}`);
|
|
171
|
+
if (t === undefined)
|
|
172
|
+
continue;
|
|
173
|
+
const words = countWords(t);
|
|
174
|
+
tiered.set(tier, { tier, text: t, words, from: "tiered" });
|
|
175
|
+
sizeWarning(`obs:summary:${tier}`, words, SIZES.tiers[tier].target[1], warnings);
|
|
176
|
+
}
|
|
177
|
+
const bare = text("obs:summary");
|
|
178
|
+
if (bare !== undefined) {
|
|
179
|
+
const words = countWords(bare);
|
|
180
|
+
const tier = tierFor(words);
|
|
181
|
+
if (tiered.has(tier)) {
|
|
182
|
+
warnings.push(`obs:summary (${words} words) falls in the ${tier} tier, which obs:summary:${tier} already fills; it is set aside (§4)`);
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
tiered.set(tier, { tier, text: bare, words, from: "inferred" });
|
|
186
|
+
sizeWarning("obs:summary", words, SIZES.tiers[tier].target[1], warnings);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const summaries = TIERS.flatMap((t) => (tiered.has(t) ? [tiered.get(t)] : []));
|
|
190
|
+
if (summaries.length === 0)
|
|
191
|
+
errors.push("No summary: obs:summary or a tiered summary is required (§3)");
|
|
192
|
+
if (errors.length > 0)
|
|
193
|
+
return { errors, warnings };
|
|
194
|
+
// Optional forms
|
|
195
|
+
const title = text("obs:title");
|
|
196
|
+
if (title !== undefined)
|
|
197
|
+
sizeWarning("obs:title", countWords(title), SIZES.title, warnings);
|
|
198
|
+
const headline = text("obs:headline");
|
|
199
|
+
if (headline !== undefined)
|
|
200
|
+
sizeWarning("obs:headline", countWords(headline), SIZES.headline, warnings);
|
|
201
|
+
// Keep lines (§6)
|
|
202
|
+
const keepAll = (bag.get("obs:keep") ?? [])
|
|
203
|
+
.map((raw) => cleanText("obs:keep", raw, warnings))
|
|
204
|
+
.filter((v) => v !== undefined);
|
|
205
|
+
if (keepAll.length > SIZES.keepMax) {
|
|
206
|
+
warnings.push(`obs:keep appears ${keepAll.length} times; only the first ${SIZES.keepMax} are used (§3)`);
|
|
207
|
+
}
|
|
208
|
+
const keep = keepAll.slice(0, SIZES.keepMax);
|
|
209
|
+
keep.forEach((k, i) => sizeWarning(`obs:keep #${i + 1}`, countWords(k), SIZES.keep, warnings));
|
|
210
|
+
// Derivation (§5)
|
|
211
|
+
const mode = enumValue("obs:derive", first(bag, "obs:derive", warnings), ["verbatim", "anchor", "open"], "anchor", warnings);
|
|
212
|
+
const translate = enumValue("obs:derive:translate", first(bag, "obs:derive:translate", warnings), ["yes", "no"], "yes", warnings) === "yes";
|
|
213
|
+
const combine = enumValue("obs:derive:combine", first(bag, "obs:derive:combine", warnings), ["yes", "no"], "yes", warnings) === "yes";
|
|
214
|
+
const attribution = text("obs:attribution");
|
|
215
|
+
if (attribution !== undefined)
|
|
216
|
+
sizeWarning("obs:attribution", countWords(attribution), SIZES.attribution, warnings);
|
|
217
|
+
const preference = enumValue("obs:preference", first(bag, "obs:preference", warnings), ["summary", "full"], "summary", warnings);
|
|
218
|
+
const lang = first(bag, "obs:lang", warnings)?.trim() || ctx.lang || undefined;
|
|
219
|
+
// Identity (§11)
|
|
220
|
+
let url;
|
|
221
|
+
let describesThisPage = true;
|
|
222
|
+
const rawUrl = first(bag, "obs:url", warnings)?.trim();
|
|
223
|
+
if (rawUrl) {
|
|
224
|
+
url = normalizeUrl(rawUrl, ctx.canonical);
|
|
225
|
+
if (url === undefined) {
|
|
226
|
+
warnings.push(`obs:url: "${rawUrl}" is not an http or https URL; it is ignored`);
|
|
227
|
+
}
|
|
228
|
+
else if (ctx.canonical !== undefined) {
|
|
229
|
+
describesThisPage = url === normalizeUrl(ctx.canonical);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const brief = {
|
|
233
|
+
version,
|
|
234
|
+
modified: modified,
|
|
235
|
+
describesThisPage,
|
|
236
|
+
summaries,
|
|
237
|
+
keep,
|
|
238
|
+
derive: { mode, translate, combine },
|
|
239
|
+
preference,
|
|
240
|
+
};
|
|
241
|
+
if (url !== undefined)
|
|
242
|
+
brief.url = url;
|
|
243
|
+
if (title !== undefined)
|
|
244
|
+
brief.title = title;
|
|
245
|
+
if (headline !== undefined)
|
|
246
|
+
brief.headline = headline;
|
|
247
|
+
if (attribution !== undefined)
|
|
248
|
+
brief.attribution = attribution;
|
|
249
|
+
if (lang !== undefined)
|
|
250
|
+
brief.lang = lang;
|
|
251
|
+
return { brief, errors, warnings };
|
|
252
|
+
}
|
|
253
|
+
function guard(fn) {
|
|
254
|
+
try {
|
|
255
|
+
return fn();
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
return { errors: [`Could not read the tags: ${err instanceof Error ? err.message : String(err)}`], warnings: [] };
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// Pages (§3.1)
|
|
263
|
+
/**
|
|
264
|
+
* Reads a page's obs: meta tags. `pageUrl` is where the page was fetched
|
|
265
|
+
* from; the page's <link rel="canonical"> takes precedence for §11.
|
|
266
|
+
*/
|
|
267
|
+
export function parseObs(doc, pageUrl) {
|
|
268
|
+
return guard(() => {
|
|
269
|
+
const bag = new Map();
|
|
270
|
+
const warnings = [];
|
|
271
|
+
let inBody = false;
|
|
272
|
+
for (const meta of Array.from(doc.getElementsByTagName("meta"))) {
|
|
273
|
+
const key = (meta.getAttribute("property") ?? meta.getAttribute("name") ?? "").trim().toLowerCase();
|
|
274
|
+
if (!key.startsWith("obs:"))
|
|
275
|
+
continue;
|
|
276
|
+
const content = meta.getAttribute("content");
|
|
277
|
+
if (content === null)
|
|
278
|
+
continue;
|
|
279
|
+
if (content.trim() === "")
|
|
280
|
+
continue;
|
|
281
|
+
if (!inBody && doc.body && doc.body.contains(meta))
|
|
282
|
+
inBody = true;
|
|
283
|
+
put(bag, key, content);
|
|
284
|
+
}
|
|
285
|
+
if (bag.size === 0)
|
|
286
|
+
return { errors: ["No obs: tags on the page"], warnings };
|
|
287
|
+
if (inBody)
|
|
288
|
+
warnings.push("Some obs: tags are in <body>; they belong in <head> (§3.1)");
|
|
289
|
+
const canonicalHref = doc.querySelector('link[rel~="canonical"]')?.getAttribute("href") ?? undefined;
|
|
290
|
+
const canonical = (canonicalHref && normalizeUrl(canonicalHref, pageUrl)) || normalizeUrl(pageUrl) || undefined;
|
|
291
|
+
const lang = doc.documentElement?.getAttribute("lang")?.trim() || undefined;
|
|
292
|
+
const ctx = {};
|
|
293
|
+
if (canonical !== undefined)
|
|
294
|
+
ctx.canonical = canonical;
|
|
295
|
+
if (lang !== undefined)
|
|
296
|
+
ctx.lang = lang;
|
|
297
|
+
return build(bag, ctx, warnings);
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
// Feeds (§13)
|
|
302
|
+
function obsChildren(item) {
|
|
303
|
+
return Array.from(item.children).filter((el) => {
|
|
304
|
+
if (el.namespaceURI === OBS_NS)
|
|
305
|
+
return true;
|
|
306
|
+
// Lenient: a feed that forgot to declare the namespace but used the prefix.
|
|
307
|
+
return el.namespaceURI === null && el.tagName.toLowerCase().startsWith("obs:");
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function localName(el) {
|
|
311
|
+
const name = el.namespaceURI === OBS_NS ? el.localName : el.tagName.slice(4);
|
|
312
|
+
return name.toLowerCase();
|
|
313
|
+
}
|
|
314
|
+
function itemLink(item) {
|
|
315
|
+
for (const el of Array.from(item.children)) {
|
|
316
|
+
if (el.namespaceURI === OBS_NS)
|
|
317
|
+
continue;
|
|
318
|
+
if (el.localName !== "link")
|
|
319
|
+
continue;
|
|
320
|
+
const href = el.getAttribute("href");
|
|
321
|
+
if (href !== null) {
|
|
322
|
+
const rel = el.getAttribute("rel");
|
|
323
|
+
if (rel === null || rel === "alternate")
|
|
324
|
+
return href.trim();
|
|
325
|
+
}
|
|
326
|
+
else if (el.textContent?.trim()) {
|
|
327
|
+
return el.textContent.trim();
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Reads the OBS elements of one RSS <item> or Atom <entry>. `itemUrl`
|
|
334
|
+
* overrides the item's own link when given.
|
|
335
|
+
*/
|
|
336
|
+
export function parseFeedObs(item, itemUrl) {
|
|
337
|
+
return guard(() => {
|
|
338
|
+
const bag = new Map();
|
|
339
|
+
const warnings = [];
|
|
340
|
+
for (const el of obsChildren(item)) {
|
|
341
|
+
const name = localName(el);
|
|
342
|
+
const value = el.textContent ?? "";
|
|
343
|
+
if (name === "summary") {
|
|
344
|
+
const length = el.getAttribute("length")?.trim().toLowerCase();
|
|
345
|
+
if (length === undefined || length === "")
|
|
346
|
+
put(bag, "obs:summary", value);
|
|
347
|
+
else if (TIERS.includes(length))
|
|
348
|
+
put(bag, `obs:summary:${length}`, value);
|
|
349
|
+
else
|
|
350
|
+
warnings.push(`<obs:summary length="${length}"> is not short, medium or long; it is ignored`);
|
|
351
|
+
}
|
|
352
|
+
else if (name === "derive") {
|
|
353
|
+
if (value.trim() !== "")
|
|
354
|
+
put(bag, "obs:derive", value);
|
|
355
|
+
const tr = el.getAttribute("translate");
|
|
356
|
+
if (tr !== null)
|
|
357
|
+
put(bag, "obs:derive:translate", tr);
|
|
358
|
+
const co = el.getAttribute("combine");
|
|
359
|
+
if (co !== null)
|
|
360
|
+
put(bag, "obs:derive:combine", co);
|
|
361
|
+
}
|
|
362
|
+
else {
|
|
363
|
+
put(bag, `obs:${name}`, value);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
if (bag.size === 0)
|
|
367
|
+
return { errors: ["No OBS elements on the item"], warnings };
|
|
368
|
+
const link = itemUrl ?? itemLink(item);
|
|
369
|
+
const canonical = link ? normalizeUrl(link) : undefined;
|
|
370
|
+
const ctx = {};
|
|
371
|
+
if (canonical !== undefined)
|
|
372
|
+
ctx.canonical = canonical;
|
|
373
|
+
return build(bag, ctx, warnings);
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
// Objects already in the ObsBrief shape (an ingest payload, a JSON file)
|
|
378
|
+
/** Checks an object in the ObsBrief shape and rebuilds it under the same rules. */
|
|
379
|
+
export function validateObs(input) {
|
|
380
|
+
return guard(() => {
|
|
381
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
382
|
+
return { errors: ["Expected an object in the ObsBrief shape"], warnings: [] };
|
|
383
|
+
}
|
|
384
|
+
const o = input;
|
|
385
|
+
const bag = new Map();
|
|
386
|
+
const warnings = [];
|
|
387
|
+
const str = (v) => (typeof v === "string" ? v : undefined);
|
|
388
|
+
const set = (key, v) => {
|
|
389
|
+
const s = str(v);
|
|
390
|
+
if (s !== undefined)
|
|
391
|
+
put(bag, key, s);
|
|
392
|
+
else if (v !== undefined && v !== null)
|
|
393
|
+
warnings.push(`${key} is not a string; it is ignored`);
|
|
394
|
+
};
|
|
395
|
+
set("obs:modified", o.modified);
|
|
396
|
+
if (typeof o.version === "number")
|
|
397
|
+
put(bag, "obs:version", String(o.version));
|
|
398
|
+
set("obs:url", o.url);
|
|
399
|
+
set("obs:title", o.title);
|
|
400
|
+
set("obs:headline", o.headline);
|
|
401
|
+
set("obs:attribution", o.attribution);
|
|
402
|
+
set("obs:preference", o.preference);
|
|
403
|
+
set("obs:lang", o.lang);
|
|
404
|
+
if (Array.isArray(o.summaries)) {
|
|
405
|
+
for (const s of o.summaries) {
|
|
406
|
+
if (s && typeof s === "object") {
|
|
407
|
+
const rec = s;
|
|
408
|
+
const tier = str(rec.tier);
|
|
409
|
+
const t = str(rec.text);
|
|
410
|
+
if (t === undefined)
|
|
411
|
+
continue;
|
|
412
|
+
// A summary whose tier was inferred is re-inferred from its words.
|
|
413
|
+
if (rec.from === "inferred" || tier === undefined)
|
|
414
|
+
put(bag, "obs:summary", t);
|
|
415
|
+
else if (TIERS.includes(tier))
|
|
416
|
+
put(bag, `obs:summary:${tier}`, t);
|
|
417
|
+
else
|
|
418
|
+
warnings.push(`A summary has tier "${tier}"; it is ignored`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (Array.isArray(o.keep))
|
|
423
|
+
for (const k of o.keep)
|
|
424
|
+
set("obs:keep", k);
|
|
425
|
+
if (o.derive && typeof o.derive === "object") {
|
|
426
|
+
const d = o.derive;
|
|
427
|
+
set("obs:derive", d.mode);
|
|
428
|
+
if (typeof d.translate === "boolean")
|
|
429
|
+
put(bag, "obs:derive:translate", d.translate ? "yes" : "no");
|
|
430
|
+
if (typeof d.combine === "boolean")
|
|
431
|
+
put(bag, "obs:derive:combine", d.combine ? "yes" : "no");
|
|
432
|
+
}
|
|
433
|
+
const result = build(bag, {}, warnings);
|
|
434
|
+
if (result.brief && typeof o.describesThisPage === "boolean") {
|
|
435
|
+
result.brief.describesThisPage = o.describesThisPage;
|
|
436
|
+
}
|
|
437
|
+
return result;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Feed against page (§13)
|
|
442
|
+
/** The later obs:modified stands; on a tie, the page stands. */
|
|
443
|
+
export function mergeObs(page, feed) {
|
|
444
|
+
if (!page)
|
|
445
|
+
return feed;
|
|
446
|
+
if (!feed)
|
|
447
|
+
return page;
|
|
448
|
+
return Date.parse(feed.modified) > Date.parse(page.modified) ? feed : page;
|
|
449
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: bad-modified</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/bad-modified/">
|
|
7
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone.">
|
|
8
|
+
<meta property="obs:modified" content="last Tuesday">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: band-120</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/band-120/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning.">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: band-121</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/band-121/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most.">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: band-500</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/band-500/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: band-501</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/band-501/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The.">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: body-tags</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/body-tags/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
11
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone.">
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: derive-bad</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/derive-bad/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone.">
|
|
9
|
+
<meta property="obs:derive" content="loose">
|
|
10
|
+
<meta property="obs:derive:translate" content="maybe">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: derive-verbatim</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/derive-verbatim/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone.">
|
|
9
|
+
<meta property="obs:derive" content="VERBATIM">
|
|
10
|
+
<meta property="obs:derive:combine" content="no">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head prefix="obs: https://openbriefingstandard.org/ns#">
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Garden Club Notes: duplicate-headline</title>
|
|
6
|
+
<link rel="canonical" href="https://example.test/duplicate-headline/">
|
|
7
|
+
<meta property="obs:modified" content="2026-11-08T14:00:00Z">
|
|
8
|
+
<meta property="obs:summary" content="The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month. The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone.">
|
|
9
|
+
<meta property="obs:headline" content="The garden club planned its spring planting, and volunteers will water the beds every second morning.">
|
|
10
|
+
<meta property="obs:headline" content="A second headline that is ignored.">
|
|
11
|
+
</head>
|
|
12
|
+
<body>
|
|
13
|
+
<article><h1>Garden Club Notes</h1><p>The garden club met on Tuesday to plan the spring planting. Volunteers agreed to water the beds every second morning. Most of the seeds came from last year's harvest. The committee chose tomatoes, beans and three kinds of squash. A new fence will keep the rabbits away from the lettuce. Members who cannot dig offered to label the rows instead. The treasurer reported enough money for compost and tools. Children from the school will help on the first Saturday. Anyone may take vegetables home once the harvest begins. The next meeting is set for the first week of the month.</p></article>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|