wiki-formant 0.46.0 → 0.47.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 CHANGED
@@ -431,6 +431,13 @@ The schema.org nodes sit beside it, because they state the same facts to a diffe
431
431
  <JsonLd data={articleLd({ headline: title, url, image, published, modified, publisher: { '@id': `${SITE_URL}/#organization` }, citation: citationsFromReferences(refs) })} />
432
432
  ```
433
433
 
434
+ `questionPairs(html)` reads a page's FAQ out of its own headings: every `h2`/`h3` ending in `?`, answered by the text beneath it. It never rewrites a statement heading as a question, so a page earns `FAQPage` by being written as one. `faqPageLd(pairs, url)` is that node, or null under two pairs, and takes an authored list just as well.
435
+
436
+ ```tsx
437
+ const faq = faqPageLd(questionPairs(html), url);
438
+ {faq && <JsonLd data={faq} />}
439
+ ```
440
+
434
441
  `sitePageMetadata({ siteName, handle })` binds the three fields Next drops from every page that sets its own card, with `locale` defaulting to `en_US`. `siteGraphLd` is the Organization, WebSite (with its `SearchAction`) and `WebAPI` as one `@graph` for the root layout, every node carrying an `@id`, and `siteRefs(url)` hands out the same ids for a page's `publisher` and `isPartOf` — so no page writes an inline copy of the publisher that can disagree with the layout's.
435
442
 
436
443
  ```ts
@@ -121,6 +121,27 @@ export declare function collectionLd(o: CollectionLdOptions): LdNode;
121
121
  * an authored `&amp;` is not what a crawler reads.
122
122
  */
123
123
  export declare function citationsFromReferences(items: readonly ReferenceItem[], max?: number): LdNode[];
124
+ export interface QaPair {
125
+ q: string;
126
+ a: string;
127
+ }
128
+ /**
129
+ * The question/answer pairs in a page's HTML: every `h2`/`h3` that ends in a
130
+ * question mark, with the text up to the next such heading as its answer.
131
+ *
132
+ * It never turns a statement heading into a question. Markup that rewrites
133
+ * "Protocol fees" as "What is Protocol fees?" claims a question the page never
134
+ * asked, so a page earns an FAQ by being written as one. An answer under 40
135
+ * characters is a heading with no section, and is skipped.
136
+ */
137
+ export declare function questionPairs(html: string): QaPair[];
138
+ /**
139
+ * `FAQPage` over question/answer pairs, or null under two. One question is an
140
+ * article with a question for a heading, not an FAQ. Pairs come from
141
+ * `questionPairs` on stored HTML, or from a list the caller authors directly
142
+ * as plain text, so a home-page FAQ and a wiki article render the same node.
143
+ */
144
+ export declare function faqPageLd(pairs: readonly QaPair[], url: string): LdNode | null;
124
145
  /**
125
146
  * Characters a document `<title>` has before a search result truncates it.
126
147
  *
package/dist/metadata.js CHANGED
@@ -107,6 +107,55 @@ export function citationsFromReferences(items, max = 50) {
107
107
  })
108
108
  .slice(0, max);
109
109
  }
110
+ /**
111
+ * The question/answer pairs in a page's HTML: every `h2`/`h3` that ends in a
112
+ * question mark, with the text up to the next such heading as its answer.
113
+ *
114
+ * It never turns a statement heading into a question. Markup that rewrites
115
+ * "Protocol fees" as "What is Protocol fees?" claims a question the page never
116
+ * asked, so a page earns an FAQ by being written as one. An answer under 40
117
+ * characters is a heading with no section, and is skipped.
118
+ */
119
+ export function questionPairs(html) {
120
+ // Plain text, not `stripHtml`, which keeps an href in parentheses for a model
121
+ // following citations: a heading's permalink anchor would end it in "(#id)"
122
+ // rather than "?". The anchor itself goes, glyph and all.
123
+ const plain = (s) => decodeEntities(s.replace(/<a\b[^>]*href="#[^"]*"[^>]*>[\s\S]*?<\/a>/gi, '').replace(/<[^>]+>/g, ' '))
124
+ .replace(/\s+/g, ' ')
125
+ .trim();
126
+ const pairs = [];
127
+ const sections = html.split(/<h[23][^>]*>/i);
128
+ for (const section of sections.slice(1)) {
129
+ const [heading = '', ...rest] = section.split(/<\/h[23]>/i);
130
+ const q = plain(heading).replace(/\s+([?.,;:!])/g, '$1');
131
+ if (!q.endsWith('?'))
132
+ continue;
133
+ const a = plain(rest.join('')).replace(/\s+([?.,;:!])/g, '$1');
134
+ if (a.length >= 40)
135
+ pairs.push({ q, a: a.slice(0, 1000) });
136
+ }
137
+ return pairs;
138
+ }
139
+ /**
140
+ * `FAQPage` over question/answer pairs, or null under two. One question is an
141
+ * article with a question for a heading, not an FAQ. Pairs come from
142
+ * `questionPairs` on stored HTML, or from a list the caller authors directly
143
+ * as plain text, so a home-page FAQ and a wiki article render the same node.
144
+ */
145
+ export function faqPageLd(pairs, url) {
146
+ if (pairs.length < 2)
147
+ return null;
148
+ return {
149
+ '@context': 'https://schema.org',
150
+ '@type': 'FAQPage',
151
+ url,
152
+ mainEntity: pairs.map(p => ({
153
+ '@type': 'Question',
154
+ name: p.q,
155
+ acceptedAnswer: { '@type': 'Answer', text: p.a },
156
+ })),
157
+ };
158
+ }
110
159
  /**
111
160
  * Characters a document `<title>` has before a search result truncates it.
112
161
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-formant",
3
- "version": "0.46.0",
3
+ "version": "0.47.1",
4
4
  "description": "The portable half of a wiki: derived taxonomy and facet controls, a version-negotiating MCP transport, markdown twins, block rendering, a rich-text editor engine, conditional-GET plumbing, and the headers and CSP every origin sends.",
5
5
  "type": "module",
6
6
  "license": "MIT",