@relvehq/agent-ready 0.4.1 → 0.4.3

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
@@ -18,7 +18,7 @@ they got before.
18
18
  On a real tool page: raw HTML 97,944 tokens, a generic converter 4,618, a
19
19
  refined file 1,695.
20
20
 
21
- **Start here:** the onboarding guide at <https://relvehq.com/agent-ready>
21
+ **Start here:** the onboarding guide at <https://engine.relve.ai/search/agent-ready>
22
22
  (every stack, the WordPress plugin download, and how to test it).
23
23
 
24
24
  ## Requirements
@@ -135,11 +135,18 @@ choose what to serve: that would be cloaking.
135
135
  1. a **template** for the route (`"/blog/:slug"`), written from your data
136
136
  2. a **stored** file (`store.get(path)`), e.g. one written by `refine`
137
137
  3. **convert**: fetch the page's own HTML (public, no cookies) and turn its
138
- main content into Markdown (Readability + turndown, GFM tables). Under 200
139
- characters of real text is discarded.
138
+ main content into Markdown (Readability + turndown, GFM tables).
139
+ Readability is used only when it keeps most of `<main>`'s text, numbers
140
+ and tables; otherwise `<main>` is converted whole. Closed accordion
141
+ answers are kept, loading placeholders ("Loading…", skeletons) are
142
+ dropped, and FAQ answers the HTML lacks are filled in word for word from
143
+ the page's FAQPage JSON-LD, under the page's own FAQ heading (never added
144
+ to a page that shows no FAQ). Under 200 characters of real text is
145
+ discarded.
140
146
  4. nothing usable: an `Accept` request gets the normal HTML page, a `.md`
141
147
  request gets a 404
142
- 4. **Enrich**: YAML front matter (title, canonical, source), facts from JSON-LD, token count.
148
+ 4. **Enrich**: YAML front matter (title, description, canonical, source), facts from JSON-LD, token count.
149
+ A doc that brings its own front matter keeps it; only a missing title or description is added.
143
150
  5. **Respond**:
144
151
 
145
152
  ```
@@ -367,6 +374,7 @@ if (d.markdown) {
367
374
  | `internalPrefix` | `/__md` | rewrite target |
368
375
  | `fetchTimeoutMs` | `8000` | page fetch for convert |
369
376
  | `minChars` | `200` | quality gate for convert |
377
+ | `maxHtmlBytes` | `5242880` (5 MB) | largest page HTML convert reads; raise it for one very large page |
370
378
 
371
379
  ## Commands
372
380
 
package/SETUP.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Setting up @relvehq/agent-ready
2
2
 
3
- A step-by-step guide for the developer who adds this package to a site. The onboarding page with the same steps, the WordPress plugin download and a test block: https://relvehq.com/agent-ready
3
+ A step-by-step guide for the developer who adds this package to a site. The onboarding page with the same steps, the WordPress plugin download and a test block: https://engine.relve.ai/search/agent-ready
4
4
 
5
5
  ## The one command
6
6
 
@@ -107,6 +107,8 @@ export interface AgentReadyConfig {
107
107
  fetchTimeoutMs?: number;
108
108
  /** Quality gate: converted Markdown with less plain text than this is discarded. Default 200. */
109
109
  minChars?: number;
110
+ /** Largest page HTML the converter reads; bigger pages get no Markdown. Default 5 MB. */
111
+ maxHtmlBytes?: number;
110
112
  }
111
113
  export interface ResolvedAgentReadyConfig {
112
114
  readonly __resolved: true;
@@ -127,6 +129,7 @@ export interface ResolvedAgentReadyConfig {
127
129
  fetchOrigin: ((request: Request) => string) | null;
128
130
  fetchTimeoutMs: number;
129
131
  minChars: number;
132
+ maxHtmlBytes: number;
130
133
  }
131
134
  /** Never served as Markdown, whatever the venture's config says. */
132
135
  export declare const ALWAYS_EXCLUDE: readonly string[];
@@ -134,6 +137,8 @@ export declare const DEFAULT_CACHE_CONTROL = "public, s-maxage=300, stale-while-
134
137
  export declare const DEFAULT_NEGOTIATED_CACHE_CONTROL = "private, max-age=300";
135
138
  export declare const DEFAULT_INTERNAL_PREFIX = "/__md";
136
139
  export declare const DEFAULT_INTERNAL_HEADER = "x-agent-ready-internal";
140
+ /** Default cap on the page HTML the converter reads (maxHtmlBytes). */
141
+ export declare const DEFAULT_MAX_HTML_BYTES: number;
137
142
  /** Request header the middleware adapter sets on its rewrite: how Markdown was asked for. */
138
143
  export declare const VIA_HEADER = "x-agent-ready-via";
139
144
  export declare function defineAgentReady(config: AgentReadyConfig | ResolvedAgentReadyConfig): ResolvedAgentReadyConfig;
@@ -7,7 +7,7 @@
7
7
  //
8
8
  // EDGE-SAFE. No Node APIs. Middleware bundles import this module.
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.VIA_HEADER = exports.DEFAULT_INTERNAL_HEADER = exports.DEFAULT_INTERNAL_PREFIX = exports.DEFAULT_NEGOTIATED_CACHE_CONTROL = exports.DEFAULT_CACHE_CONTROL = exports.ALWAYS_EXCLUDE = void 0;
10
+ exports.VIA_HEADER = exports.DEFAULT_MAX_HTML_BYTES = exports.DEFAULT_INTERNAL_HEADER = exports.DEFAULT_INTERNAL_PREFIX = exports.DEFAULT_NEGOTIATED_CACHE_CONTROL = exports.DEFAULT_CACHE_CONTROL = exports.ALWAYS_EXCLUDE = void 0;
11
11
  exports.defineAgentReady = defineAgentReady;
12
12
  exports.isEnabled = isEnabled;
13
13
  exports.isExcluded = isExcluded;
@@ -30,6 +30,8 @@ exports.DEFAULT_CACHE_CONTROL = "public, s-maxage=300, stale-while-revalidate=86
30
30
  exports.DEFAULT_NEGOTIATED_CACHE_CONTROL = "private, max-age=300";
31
31
  exports.DEFAULT_INTERNAL_PREFIX = "/__md";
32
32
  exports.DEFAULT_INTERNAL_HEADER = "x-agent-ready-internal";
33
+ /** Default cap on the page HTML the converter reads (maxHtmlBytes). */
34
+ exports.DEFAULT_MAX_HTML_BYTES = 5 * 1024 * 1024;
33
35
  /** Request header the middleware adapter sets on its rewrite: how Markdown was asked for. */
34
36
  exports.VIA_HEADER = "x-agent-ready-via";
35
37
  function defineAgentReady(config) {
@@ -57,6 +59,7 @@ function defineAgentReady(config) {
57
59
  fetchOrigin: c.fetchOrigin ?? null,
58
60
  fetchTimeoutMs: c.fetchTimeoutMs ?? 8000,
59
61
  minChars: c.minChars ?? 200,
62
+ maxHtmlBytes: c.maxHtmlBytes && c.maxHtmlBytes > 0 ? c.maxHtmlBytes : exports.DEFAULT_MAX_HTML_BYTES,
60
63
  };
61
64
  }
62
65
  /** The master switch. A throwing `enabled` function counts as off. */
@@ -19,4 +19,21 @@ export declare function htmlToMarkdown(html: string, opts: ConvertOptions): Conv
19
19
  * Scripts never run here, so do the swap ourselves before [hidden] is stripped.
20
20
  */
21
21
  export declare function unfoldNextStreaming(doc: El): void;
22
+ /**
23
+ * Accordions and FAQ toggles: the question is a <button> wired to its panel by
24
+ * aria-controls, and a closed panel is [hidden] (or rendered empty). Show the
25
+ * panel and keep the question as text, before buttons and [hidden] are
26
+ * stripped. Tabs, popups and menus are left alone, and so is a panel that is a
27
+ * menu, listbox or dialog or holds navigation; a trigger whose panel isn't on
28
+ * the page is left too. Hidden elements whose class names an accordion or FAQ
29
+ * are shown as well.
30
+ */
31
+ export declare function openDisclosures(doc: El): void;
32
+ /**
33
+ * Loading placeholders: busy regions, progress bars, skeleton / shimmer /
34
+ * spinner / loader elements (small, no media), and blocks whose whole text is
35
+ * a loading message ("Loading reviews…"). Real text that only starts with
36
+ * "Loading" stays: the message needs an ellipsis, or to be the bare word.
37
+ */
38
+ export declare function dropPlaceholders(doc: El): void;
22
39
  export {};
@@ -4,13 +4,17 @@
4
4
  // 1. Read metadata (title, description, lang, canonical, JSON-LD) from <head>.
5
5
  // 2. Unfold Next.js streamed Suspense content (<div hidden id="S:n">) into
6
6
  // its placeholder, so App Router pages convert with their real content.
7
- // 3. Strip chrome: script/style/nav/footer/forms/dialogs/cookie banners.
8
- // 4. Pick the main content: Mozilla Readability, unless it returns too little
9
- // compared with <main>/<article>/<body>, which are used instead.
10
- // 5. Absolutize links and images, flatten block-level links, give header-less
7
+ // 3. Open accordions (keep the question, show the panel) and drop loading
8
+ // placeholders, before buttons and [hidden] go.
9
+ // 4. Strip chrome: script/style/nav/footer/forms/dialogs/cookie banners.
10
+ // 5. Pick the main content: Mozilla Readability, unless it returns too little
11
+ // of <main>/<article>/<body> (text, numbers or tables), which are used instead.
12
+ // 6. Absolutize links and images, flatten block-level links, give header-less
11
13
  // tables a header row so GFM can render them.
12
- // 6. Turndown + GFM (tables, strikethrough, task lists), then collapse whitespace.
13
- // 7. Quality gate: under `minChars` of plain text -> null (caller serves HTML).
14
+ // 7. Turndown + GFM (tables, strikethrough, task lists), then collapse whitespace.
15
+ // 8. FAQ answers the HTML lacks (closed accordion panels are often empty)
16
+ // are filled from the FAQPage JSON-LD, under the page's own FAQ heading.
17
+ // 9. Quality gate: under `minChars` of plain text -> null (caller serves HTML).
14
18
  //
15
19
  // NODE ONLY (linkedom, turndown). Never import this from middleware.
16
20
  var __importDefault = (this && this.__importDefault) || function (mod) {
@@ -19,10 +23,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
19
23
  Object.defineProperty(exports, "__esModule", { value: true });
20
24
  exports.htmlToMarkdown = htmlToMarkdown;
21
25
  exports.unfoldNextStreaming = unfoldNextStreaming;
26
+ exports.openDisclosures = openDisclosures;
27
+ exports.dropPlaceholders = dropPlaceholders;
22
28
  const linkedom_1 = require("linkedom");
23
29
  const readability_1 = require("@mozilla/readability");
24
30
  const turndown_1 = __importDefault(require("turndown"));
25
31
  const turndown_plugin_gfm_1 = require("turndown-plugin-gfm");
32
+ const faq_1 = require("./faq");
26
33
  const JUNK = [
27
34
  "script",
28
35
  "style",
@@ -57,6 +64,19 @@ const JUNK = [
57
64
  ].join(",");
58
65
  const COOKIE_RE = /(^|[\s_-])(cookie|cookies|consent|gdpr|onetrust|cookiebot|cc-window|cc-banner|cmp)([\s_-]|$)/i;
59
66
  const BLOCK = "div,p,section,article,header,ul,ol,li,table,h1,h2,h3,h4,h5,h6,blockquote,pre,figure";
67
+ // Accordions (changes/agent-ready-quality).
68
+ const DISCLOSURE_CLASS_RE = /(^|[\s_-])(accordion|faqs?)([\s_-]|$)/i;
69
+ const NOT_A_DISCLOSURE_ROLE = /^(tab|menuitem|combobox|option)$/i;
70
+ const NOT_A_PANEL_ROLE = /^(menu|menubar|listbox|dialog|alertdialog|navigation|tree|grid)$/i;
71
+ // Expander labels are controls, not content: their panel opens, the label goes.
72
+ const CONTROL_LABEL_RE = /^(?:(?:show|read|view|see|load)\s+(?:more|less|all)|expand|collapse|toggle|open|close)\b/i;
73
+ // Loading placeholders.
74
+ const PLACEHOLDER_RE = /(^|[\s_-])(skeleton|shimmer|spinner|loader|loading)([\s_-]|$)|(^|\s)animate-(pulse|spin)(\s|$)/i;
75
+ const LOADING_TEXT_RE = /^(?:loading|fetching|please wait)\b[^.!?]{0,40}(?:…|\.{3})$|^(?:loading|fetching|please wait)\.?$/i;
76
+ const PLACEHOLDER_MAX_TEXT = 200;
77
+ // Data-heavy pages: Readability must keep this share of <main>'s numbers and tables.
78
+ const KEEP_DATA_SHARE = 0.8;
79
+ const MIN_NUMBERS_TO_JUDGE = 10;
60
80
  let turndown = null;
61
81
  function getTurndown() {
62
82
  if (turndown)
@@ -82,6 +102,15 @@ function getTurndown() {
82
102
  return (firstCell ? "| " : " ") + text + " |";
83
103
  },
84
104
  });
105
+ // A <summary> that is only text is a question or a label: keep it bold. One
106
+ // holding a heading renders as that heading.
107
+ td.addRule("agentReadySummary", {
108
+ filter: (node) => node.nodeName === "SUMMARY" && !node.querySelector("h1, h2, h3, h4, h5, h6"),
109
+ replacement(content) {
110
+ const text = content.replace(/\s+/g, " ").trim();
111
+ return text ? `\n\n**${text}**\n\n` : "";
112
+ },
113
+ });
85
114
  td.remove(["script", "style", "noscript", "iframe", "button", "form", "svg"]);
86
115
  turndown = td;
87
116
  return td;
@@ -95,6 +124,8 @@ function htmlToMarkdown(html, opts) {
95
124
  const meta = readMeta(document, pageUrl);
96
125
  const firstH1 = textOf(document.querySelector("h1"));
97
126
  unfoldNextStreaming(document);
127
+ openDisclosures(document);
128
+ dropPlaceholders(document);
98
129
  stripChrome(document);
99
130
  const root = document.querySelector("main") || document.querySelector("article") || document.body || document.documentElement;
100
131
  const rootStrategy = document.querySelector("main")
@@ -112,8 +143,14 @@ function htmlToMarkdown(html, opts) {
112
143
  const article = new readability_1.Readability(rdoc, { charThreshold: minChars, keepClasses: false }).parse();
113
144
  const articleText = textLength(article?.textContent ?? "");
114
145
  // Readability is great on articles but can keep a fraction of a listing or
115
- // profile page. Use it only when it kept most of what <main> holds.
116
- if (article && article.content && articleText >= minChars && articleText >= rootText * 0.6) {
146
+ // profile page. Use it only when it kept most of what <main> holds: its
147
+ // text, and on data-heavy pages its numbers and tables (a tool page kept
148
+ // 65-87% of the text but 20-70% of the prices, traffic and scores).
149
+ if (article &&
150
+ article.content &&
151
+ articleText >= minChars &&
152
+ articleText >= rootText * 0.6 &&
153
+ keepsTheData(root, article.content, article.textContent ?? "")) {
117
154
  contentHtml = article.content;
118
155
  strategy = "readability";
119
156
  readabilityTitle = article.title || undefined;
@@ -131,6 +168,9 @@ function htmlToMarkdown(html, opts) {
131
168
  const heading = firstH1 || readabilityTitle || meta.title;
132
169
  if (heading && !/^#\s/m.test(markdown))
133
170
  markdown = `# ${oneLine(heading)}\n\n${markdown}`;
171
+ const faq = (0, faq_1.faqPairs)(meta.jsonLd ?? []);
172
+ if (faq.length)
173
+ markdown = (0, faq_1.fillFaqAnswers)(markdown, faq, (pair) => faqAnswerMarkdown(pair, pageUrl));
134
174
  const textLen = plainTextLength(markdown);
135
175
  if (!markdown || textLen < minChars)
136
176
  return null;
@@ -186,6 +226,96 @@ function unfoldNextStreaming(doc) {
186
226
  segment.parentNode.removeChild(segment);
187
227
  }
188
228
  }
229
+ /**
230
+ * Accordions and FAQ toggles: the question is a <button> wired to its panel by
231
+ * aria-controls, and a closed panel is [hidden] (or rendered empty). Show the
232
+ * panel and keep the question as text, before buttons and [hidden] are
233
+ * stripped. Tabs, popups and menus are left alone, and so is a panel that is a
234
+ * menu, listbox or dialog or holds navigation; a trigger whose panel isn't on
235
+ * the page is left too. Hidden elements whose class names an accordion or FAQ
236
+ * are shown as well.
237
+ */
238
+ function openDisclosures(doc) {
239
+ for (const trigger of Array.from(doc.querySelectorAll("[aria-controls][aria-expanded]"))) {
240
+ if (trigger.hasAttribute("aria-haspopup") || NOT_A_DISCLOSURE_ROLE.test(trigger.getAttribute("role") || ""))
241
+ continue;
242
+ const panels = String(trigger.getAttribute("aria-controls"))
243
+ .split(/\s+/)
244
+ .filter(Boolean)
245
+ .map((id) => byId(doc, id))
246
+ .filter(Boolean);
247
+ if (!panels.length)
248
+ continue;
249
+ const notAPanel = (p) => NOT_A_PANEL_ROLE.test(p.getAttribute("role") || "") ||
250
+ String(p.nodeName).toLowerCase() === "nav" ||
251
+ !!p.querySelector("nav, [role=navigation], [role=menu]");
252
+ if (panels.some(notAPanel))
253
+ continue;
254
+ panels.forEach(show);
255
+ keepTriggerText(doc, trigger);
256
+ }
257
+ for (const el of Array.from(doc.querySelectorAll("[class]"))) {
258
+ if (DISCLOSURE_CLASS_RE.test(el.getAttribute("class") || ""))
259
+ show(el);
260
+ }
261
+ }
262
+ function byId(doc, id) {
263
+ if (doc.getElementById)
264
+ return doc.getElementById(id);
265
+ return doc.querySelector(`[id="${id.replace(/["\\]/g, "\\$&")}"]`);
266
+ }
267
+ function show(el) {
268
+ el.removeAttribute("hidden");
269
+ if (String(el.getAttribute("aria-hidden")).toLowerCase() === "true")
270
+ el.removeAttribute("aria-hidden");
271
+ const style = el.getAttribute("style");
272
+ if (style)
273
+ el.setAttribute("style", style.replace(/(?:display\s*:\s*none|visibility\s*:\s*hidden)\s*;?/gi, ""));
274
+ }
275
+ /** A question <button> becomes its text: plain inside a heading, else a bold paragraph. */
276
+ function keepTriggerText(doc, trigger) {
277
+ if (String(trigger.nodeName).toLowerCase() !== "button" || !trigger.parentNode)
278
+ return;
279
+ for (const icon of Array.from(trigger.querySelectorAll("svg, [aria-hidden=true]")))
280
+ remove(icon);
281
+ const text = oneLine(trigger.textContent || "");
282
+ if (!text || CONTROL_LABEL_RE.test(text))
283
+ return;
284
+ if (trigger.closest && trigger.closest("h1, h2, h3, h4, h5, h6, summary, dt")) {
285
+ trigger.parentNode.replaceChild(doc.createTextNode(text), trigger);
286
+ return;
287
+ }
288
+ const p = doc.createElement("p");
289
+ const strong = doc.createElement("strong");
290
+ strong.textContent = text;
291
+ p.appendChild(strong);
292
+ trigger.parentNode.replaceChild(p, trigger);
293
+ }
294
+ /**
295
+ * Loading placeholders: busy regions, progress bars, skeleton / shimmer /
296
+ * spinner / loader elements (small, no media), and blocks whose whole text is
297
+ * a loading message ("Loading reviews…"). Real text that only starts with
298
+ * "Loading" stays: the message needs an ellipsis, or to be the bare word.
299
+ */
300
+ function dropPlaceholders(doc) {
301
+ const small = (el) => textLength(el.textContent || "") < PLACEHOLDER_MAX_TEXT && !el.querySelector("img, picture, video, table");
302
+ const skip = (el) => /^(html|body|main|img|picture|video|source|td|th)$/.test(String(el.nodeName).toLowerCase());
303
+ for (const el of Array.from(doc.querySelectorAll('[aria-busy="true"], [role="progressbar"]'))) {
304
+ if (!skip(el) && small(el))
305
+ remove(el);
306
+ }
307
+ for (const el of Array.from(doc.querySelectorAll("[id], [class]"))) {
308
+ if (skip(el))
309
+ continue;
310
+ const hay = `${el.getAttribute("id") || ""} ${el.getAttribute("class") || ""}`;
311
+ if (PLACEHOLDER_RE.test(hay) && small(el))
312
+ remove(el);
313
+ }
314
+ for (const el of Array.from(doc.querySelectorAll("p, div, span, li, section, small, em, strong"))) {
315
+ if (el.parentNode && LOADING_TEXT_RE.test(oneLine(el.textContent || "")))
316
+ remove(el);
317
+ }
318
+ }
189
319
  function stripChrome(doc) {
190
320
  for (const el of Array.from(doc.querySelectorAll(JUNK)))
191
321
  remove(el);
@@ -289,6 +419,49 @@ function headerRows(doc) {
289
419
  }
290
420
  }
291
421
  }
422
+ /**
423
+ * Readability kept the data: 80% of <main>'s distinct numbers (judged when it
424
+ * has 10 or more) and 80% of its tables.
425
+ */
426
+ function keepsTheData(root, articleHtml, articleText) {
427
+ if (!root)
428
+ return true;
429
+ const rootNums = distinctNumbers(root.textContent || "");
430
+ if (rootNums.size >= MIN_NUMBERS_TO_JUDGE) {
431
+ const kept = distinctNumbers(articleText);
432
+ let n = 0;
433
+ for (const x of rootNums)
434
+ if (kept.has(x))
435
+ n++;
436
+ if (n < rootNums.size * KEEP_DATA_SHARE)
437
+ return false;
438
+ }
439
+ const rootTables = root.querySelectorAll("table").length;
440
+ const keptTables = (articleHtml.match(/<table\b/gi) ?? []).length;
441
+ return rootTables === 0 || keptTables >= rootTables * KEEP_DATA_SHARE;
442
+ }
443
+ /** Numbers of 2+ characters, thousands separators removed ("1,400" = "1400"). */
444
+ function distinctNumbers(s) {
445
+ // Typed: under the site's tsconfig, `match() ?? []` is a union whose
446
+ // .filter callback gets `never` (the site build fails on it).
447
+ const nums = s.replace(/(\d),(?=\d{3})/g, "$1").match(/\d+(?:\.\d+)?/g) ?? [];
448
+ return new Set(nums.filter((n) => n.length > 1));
449
+ }
450
+ /** An FAQ answer from JSON-LD as Markdown: HTML through turndown, plain text as it is. */
451
+ function faqAnswerMarkdown(pair, pageUrl) {
452
+ if ((0, faq_1.answerHasHtml)(pair)) {
453
+ const { document: adoc } = (0, linkedom_1.parseHTML)(`<!doctype html><html><head></head><body>${pair.answer}</body></html>`);
454
+ absolutize(adoc, pageUrl);
455
+ const md = tidy(getTurndown().turndown(adoc.body.innerHTML));
456
+ if (md)
457
+ return md;
458
+ }
459
+ // Plain answers can carry Markdown links to site paths: make them absolute.
460
+ return (0, faq_1.plainAnswer)(pair.answerText).replace(/\]\((\/[^)\s]*)\)/g, (m, path) => {
461
+ const abs = absolute(path, pageUrl);
462
+ return abs ? `](${abs})` : m;
463
+ });
464
+ }
292
465
  function unwrap(el) {
293
466
  const parent = el.parentNode;
294
467
  if (!parent)
@@ -16,12 +16,21 @@ export declare function estimateTokens(text: string): number;
16
16
  export declare function hasFrontMatter(markdown: string): boolean;
17
17
  /**
18
18
  * Builds the final Markdown body. A doc that already carries front matter
19
- * (hand-written or stored docs usually do) is left exactly as it is, so the
20
- * package never stacks a second block on top of an author's.
19
+ * (hand-written or stored docs usually do) keeps its block, so the package
20
+ * never stacks a second one on top of an author's; only a `title` or
21
+ * `description` the block lacks is filled in from the doc's metadata.
21
22
  */
22
23
  export declare function enrich(doc: MarkdownDoc, meta: EnrichMeta): Enriched;
23
24
  /** A YAML front-matter block. Empty values are dropped; strings are JSON-quoted (valid YAML). */
24
25
  export declare function frontMatter(fields: Record<string, string | number | null | undefined>): string;
26
+ /** Top-level keys of the doc's front matter; empty when it has none. */
27
+ export declare function frontMatterKeys(markdown: string): Set<string>;
28
+ /**
29
+ * Sets one key of the doc's front matter: replaces its value (with any
30
+ * indented continuation lines) or adds it after `title`, else first. Other
31
+ * lines are kept as they are. A doc without front matter is returned unchanged.
32
+ */
33
+ export declare function setFrontMatterField(markdown: string, key: string, value: string): string;
25
34
  /**
26
35
  * A short "## Structured data" section from the page's JSON-LD, or null when
27
36
  * nothing useful is there. Only well-known, flat facts are listed; the raw
@@ -7,6 +7,8 @@ exports.estimateTokens = estimateTokens;
7
7
  exports.hasFrontMatter = hasFrontMatter;
8
8
  exports.enrich = enrich;
9
9
  exports.frontMatter = frontMatter;
10
+ exports.frontMatterKeys = frontMatterKeys;
11
+ exports.setFrontMatterField = setFrontMatterField;
10
12
  exports.jsonLdFacts = jsonLdFacts;
11
13
  /** Rough token count: ~4 characters per token for English prose. */
12
14
  function estimateTokens(text) {
@@ -17,13 +19,20 @@ function hasFrontMatter(markdown) {
17
19
  }
18
20
  /**
19
21
  * Builds the final Markdown body. A doc that already carries front matter
20
- * (hand-written or stored docs usually do) is left exactly as it is, so the
21
- * package never stacks a second block on top of an author's.
22
+ * (hand-written or stored docs usually do) keeps its block, so the package
23
+ * never stacks a second one on top of an author's; only a `title` or
24
+ * `description` the block lacks is filled in from the doc's metadata.
22
25
  */
23
26
  function enrich(doc, meta) {
24
27
  const markdown = doc.markdown.trim();
25
28
  if (hasFrontMatter(markdown)) {
26
- const body = markdown + "\n";
29
+ let md = markdown;
30
+ const keys = frontMatterKeys(md);
31
+ if (doc.title && !keys.has("title"))
32
+ md = setFrontMatterField(md, "title", doc.title);
33
+ if (doc.description && !keys.has("description"))
34
+ md = setFrontMatterField(md, "description", doc.description);
35
+ const body = md + "\n";
27
36
  return { body, tokens: estimateTokens(body) };
28
37
  }
29
38
  const fm = frontMatter({
@@ -50,6 +59,47 @@ function frontMatter(fields) {
50
59
  lines.push("---");
51
60
  return lines.join("\n");
52
61
  }
62
+ const FRONT_MATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(\r?\n|$)/;
63
+ /** Top-level keys of the doc's front matter; empty when it has none. */
64
+ function frontMatterKeys(markdown) {
65
+ const keys = new Set();
66
+ const m = FRONT_MATTER_RE.exec(markdown);
67
+ if (!m)
68
+ return keys;
69
+ for (const line of m[1].split(/\r?\n/)) {
70
+ const k = /^([A-Za-z_][\w-]*):/.exec(line);
71
+ if (k)
72
+ keys.add(k[1]);
73
+ }
74
+ return keys;
75
+ }
76
+ /**
77
+ * Sets one key of the doc's front matter: replaces its value (with any
78
+ * indented continuation lines) or adds it after `title`, else first. Other
79
+ * lines are kept as they are. A doc without front matter is returned unchanged.
80
+ */
81
+ function setFrontMatterField(markdown, key, value) {
82
+ const m = FRONT_MATTER_RE.exec(markdown);
83
+ if (!m)
84
+ return markdown;
85
+ const lines = m[1].split(/\r?\n/);
86
+ const entry = `${key}: ${yamlScalar(value)}`;
87
+ const span = (start) => {
88
+ let end = start + 1;
89
+ while (end < lines.length && /^[ \t]/.test(lines[end]))
90
+ end++;
91
+ return end;
92
+ };
93
+ const at = lines.findIndex((l) => l.startsWith(`${key}:`));
94
+ if (at >= 0) {
95
+ lines.splice(at, span(at) - at, entry);
96
+ }
97
+ else {
98
+ const title = lines.findIndex((l) => l.startsWith("title:"));
99
+ lines.splice(title >= 0 ? span(title) : 0, 0, entry);
100
+ }
101
+ return `---\n${lines.join("\n")}\n---${m[2] ? "\n" : ""}${markdown.slice(m[0].length)}`;
102
+ }
53
103
  function yamlScalar(value) {
54
104
  if (typeof value === "number")
55
105
  return String(value);
@@ -0,0 +1,39 @@
1
+ export interface FaqPair {
2
+ /** The question, on one line. */
3
+ question: string;
4
+ /** The answer as the JSON-LD gives it (may hold HTML). */
5
+ answer: string;
6
+ /** The answer as plain text: tags stripped, entities decoded, one line. */
7
+ answerText: string;
8
+ }
9
+ /** Every Question / acceptedAnswer pair of the page's FAQPage blocks, in page order, deduped by question. */
10
+ export declare function faqPairs(jsonLd: unknown[]): FaqPair[];
11
+ /** True when the answer holds HTML markup rather than plain text. */
12
+ export declare function answerHasHtml(pair: FaqPair): boolean;
13
+ /** Does the page's text (one line per block) show an FAQ heading? */
14
+ export declare function pageShowsFaq(text: string): boolean;
15
+ /** Does the Markdown have an FAQ heading? */
16
+ export declare function hasFaqSection(markdown: string): boolean;
17
+ /**
18
+ * Convert: write each answer the FAQ section lacks, word for word, under its
19
+ * question line; a question with no line is appended at the end of the
20
+ * section as a heading one level down. No FAQ heading: returned unchanged.
21
+ */
22
+ export declare function fillFaqAnswers(markdown: string, pairs: FaqPair[], render: (pair: FaqPair) => string): string;
23
+ /**
24
+ * Refine: the FAQ section holds exactly the page's questions and answers, in
25
+ * the page's order. Null when the document has no FAQ section.
26
+ */
27
+ export declare function replaceFaqSection(markdown: string, pairs: FaqPair[]): string | null;
28
+ /**
29
+ * Refine: add a "## FAQ" section with the page's questions and answers, before
30
+ * a closing "## About…" / "## Source…" section when the document has one,
31
+ * else at the end.
32
+ */
33
+ export declare function addFaqSection(markdown: string, pairs: FaqPair[]): string;
34
+ /** Pairs whose answer is not in the FAQ section word for word; null when there is no section. */
35
+ export declare function missingFaqAnswers(markdown: string, pairs: FaqPair[]): FaqPair[] | null;
36
+ /** A plain answer as a Markdown paragraph: a leading "#", ">", "- " or "1." can't start a block. */
37
+ export declare function plainAnswer(text: string): string;
38
+ /** HTML or text -> one line of plain text. */
39
+ export declare function plainText(s: string): string;