@stack-mode/site-manager-kit 1.47.0 → 1.48.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
@@ -547,7 +547,7 @@ Site Manager's `docs/wiring.md` explains the whole process and how to fix each k
547
547
  With the Blog section on, a site reads posts, categories and tags. Set the Blog posts page pattern in Site Manager (Search Listings, page addresses) to match the site's route, for example `/blog/{slug}`, so each post gets its search listing, sitemap entry and `url`.
548
548
 
549
549
  ```tsx
550
- import { blogPostingJsonLd, getPost, getPosts, pageMetadata } from "site-manager-kit";
550
+ import { blogPostingJsonLd, faqPageJsonLd, getPost, getPosts, pageMetadata, tableOfContents } from "site-manager-kit";
551
551
 
552
552
  // app/blog/page.tsx
553
553
  const { posts, paging } = await getPosts({ page, perPage: 12 });
@@ -556,6 +556,9 @@ const { posts, paging } = await getPosts({ page, perPage: 12 });
556
556
  export const generateMetadata = async ({ params }) => pageMetadata(`/blog/${(await params).slug}`);
557
557
  const post = await getPost(slug); // null: notFound()
558
558
  const ld = JSON.stringify(blogPostingJsonLd(post, { publisherName: site.business.displayName })).replace(/</g, "\\u003c");
559
+ // The article's own FAQs (empty when it has none or the site turns them off) and its table of contents:
560
+ const faqLd = faqPageJsonLd(post.faqs, post.url); // render with jsonLdScript when not null
561
+ const toc = tableOfContents(post.bodyHtml, post.tocLevels); // [{ id, text, level }], link each as #id
559
562
  <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: ld }} />
560
563
  ```
561
564
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stack-mode/site-manager-kit",
3
- "version": "1.47.0",
3
+ "version": "1.48.0",
4
4
  "description": "Reads a site's content from Site Manager and builds its metadata, sitemap, robots, redirects, forms and refreshes. For Next.js App Router sites on Vercel.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
package/src/blog.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { bp, bpWithMeta, tagsFor } from "./client";
2
- import type { BlogCategory, BlogFeed, BlogPage, BlogPost, BlogPostSummary, BlogTag, Paging } from "./types";
2
+ import type { ArticleFaq, BlogCategory, BlogFeed, BlogPage, BlogPost, BlogPostSummary, BlogTag, Paging, TocLevel } from "./types";
3
3
 
4
4
  // Reads for the Blog section. Posts are cached under the blog tag; a single post also under seo, since its search
5
5
  // listing is edited in Search Listings.
@@ -26,8 +26,53 @@ export async function getPosts(opts: PageOpts & { category?: string; tag?: strin
26
26
  return r ? { posts: r.data, paging: paging(r.meta, opts) } : { posts: [], paging: emptyPaging(opts) };
27
27
  }
28
28
 
29
- /** One published post with its body and search listing, or null. */
30
- export const getPost = (slug: string) => bp<BlogPost>(`/blog/posts/${encodeURIComponent(slug)}`, { tags: tagsFor("blog", "seo") });
29
+ /** One published post with its body, FAQs, table of contents levels and search listing, or null. */
30
+ export async function getPost(slug: string): Promise<(BlogPost & { faqs: ArticleFaq[]; tocLevels: TocLevel[] }) | null> {
31
+ const p = await bp<BlogPost>(`/blog/posts/${encodeURIComponent(slug)}`, { tags: tagsFor("blog", "seo") });
32
+ // Older Site Manager servers send neither field.
33
+ return p ? { ...p, faqs: p.faqs ?? [], tocLevels: p.tocLevels ?? [] } : null;
34
+ }
35
+
36
+ /**
37
+ * schema.org `FAQPage` structured data for an article's own FAQs, or null when it has none. Use it only on the page
38
+ * that shows them, and render it with `jsonLdScript`.
39
+ */
40
+ export function faqPageJsonLd(faqs: Pick<ArticleFaq, "question" | "answerHtml">[] | null | undefined, url?: string | null): Record<string, unknown> | null {
41
+ if (!faqs?.length) return null;
42
+ return {
43
+ "@context": "https://schema.org",
44
+ "@type": "FAQPage",
45
+ ...(url ? { url } : {}),
46
+ mainEntity: faqs.map((f) => ({ "@type": "Question", name: f.question, acceptedAnswer: { "@type": "Answer", text: f.answerHtml } })),
47
+ };
48
+ }
49
+
50
+ export type TocItem = { id: string; text: string; level: 2 | 3 };
51
+
52
+ const ENTITIES: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
53
+ const plain = (html: string) =>
54
+ html
55
+ .replace(/<[^>]+>/g, "")
56
+ .replace(/&(#\d+|[a-z]+);/gi, (m, e: string) => (e.startsWith("#") ? String.fromCharCode(Number(e.slice(1))) : (ENTITIES[e.toLowerCase()] ?? m)))
57
+ .replace(/\s+/g, " ")
58
+ .trim();
59
+
60
+ /**
61
+ * A table of contents from an article's body: its H2 (and H3, when `levels` has it) headings that carry an id, in
62
+ * order. Site Manager gives every article heading an id, so links like `#costs` work. Empty for `[]`.
63
+ */
64
+ export function tableOfContents(bodyHtml: string, levels: TocLevel[] | null | undefined): TocItem[] {
65
+ const want = new Set(levels ?? []);
66
+ if (!want.size) return [];
67
+ const out: TocItem[] = [];
68
+ for (const m of bodyHtml.matchAll(/<h([23])\b([^>]*)>([\s\S]*?)<\/h\1>/gi)) {
69
+ const level = Number(m[1]) as 2 | 3;
70
+ const id = m[2].match(/\bid="([^"]+)"/i)?.[1];
71
+ const text = plain(m[3]);
72
+ if (id && text && want.has(`h${level}` as TocLevel)) out.push({ id, text, level });
73
+ }
74
+ return out;
75
+ }
31
76
 
32
77
  export const getBlogCategories = async () => (await bp<BlogCategory[]>("/blog/categories", { tags: tagsFor("blog") })) ?? [];
33
78
 
package/src/index.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  export { bp, bpWithMeta, BlueprintError, siteId, tagsFor } from "./client";
11
11
  export * from "./data";
12
- export { blogPostingJsonLd, buildRss, getBlogCategories, getBlogCategory, getBlogFeed, getBlogTags, getPost, getPosts } from "./blog";
12
+ export { blogPostingJsonLd, buildRss, faqPageJsonLd, getBlogCategories, getBlogCategory, getBlogFeed, getBlogTags, getPost, getPosts, tableOfContents, type TocItem } from "./blog";
13
13
  export { buildMetadata, defaultMetadata, pageMetadata, titleWithSuffix, TITLE_CHAR_LIMIT } from "./metadata";
14
14
  export { buildSitemap, sitemap } from "./sitemap";
15
15
  export { buildRobots, robots } from "./robots";
package/src/types.ts CHANGED
@@ -498,11 +498,23 @@ export type BlogPostSummary = {
498
498
  tags: BlogTerm[];
499
499
  };
500
500
 
501
+ /** One of an article's own questions and answers. `answerHtml` allows bold, italic and links only. */
502
+ export type ArticleFaq = { question: string; answerHtml: string; answerText: string };
503
+ /** Heading levels an article's table of contents lists; empty for none. */
504
+ export type TocLevel = "h2" | "h3";
505
+
501
506
  export type BlogPost = BlogPostSummary & {
502
507
  /** Short takeaways, in order, up to seven. */
503
508
  keyPoints: string[];
504
509
  bodyHtml: string;
505
510
  wordCount: number;
511
+ /**
512
+ * The article's own FAQs, in order (Site Manager 2026-11; `getPost` gives `[]` from older servers). Empty when the
513
+ * site turns article FAQs off. Build FAQPage structured data with `faqPageJsonLd`.
514
+ */
515
+ faqs?: ArticleFaq[];
516
+ /** The headings the site's table of contents lists, by their ids in `bodyHtml` (`getPost` gives `[]` from older servers). */
517
+ tocLevels?: TocLevel[];
506
518
  /** The post's search listing from Site Manager, or null while it has no page. */
507
519
  seo: { title: string; description: string | null; canonical: string | null; ogImage: Image | null } | null;
508
520
  };
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const KIT_VERSION = "1.47.0";
1
+ export const KIT_VERSION = "1.48.0";