@stack-mode/site-manager-kit 1.46.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.46.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
 
@@ -113,7 +158,18 @@ export function blogPostingJsonLd(post: BlogPost, opts: { publisherName?: string
113
158
  if (image) out.image = [image];
114
159
  if (post.publishedAt) out.datePublished = post.publishedAt;
115
160
  out.dateModified = post.updatedAt;
116
- if (post.author) out.author = { "@type": "Person", name: post.author.name, ...(post.author.url ? { url: post.author.url } : {}) };
161
+ if (post.author) {
162
+ const a = post.author;
163
+ out.author = {
164
+ "@type": "Person",
165
+ name: a.name,
166
+ ...(a.url ? { url: a.url } : {}),
167
+ ...(a.jobTitle ? { jobTitle: a.jobTitle } : {}),
168
+ ...(a.credentials ? { description: a.credentials } : {}),
169
+ // Profiles elsewhere let search engines and AI tools confirm who wrote the article.
170
+ ...(a.sameAs?.length ? { sameAs: a.sameAs } : {}),
171
+ };
172
+ }
117
173
  if (opts.publisherName) out.publisher = { "@type": "Organization", name: opts.publisherName, ...(opts.publisherLogo ? { logo: { "@type": "ImageObject", url: opts.publisherLogo } } : {}) };
118
174
  if (post.categories.length) out.articleSection = post.categories.map((c) => c.name);
119
175
  if (post.tags.length) out.keywords = post.tags.map((t) => t.name).join(", ");
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
@@ -476,7 +476,11 @@ export type BlogTerm = { slug: string; name: string };
476
476
  * Who wrote a post. A `team` author is one of the site's Team members: `slug` is theirs, and `url` their page address,
477
477
  * null until the site has a Team page pattern in Site Manager. Both are absent for other kinds and older Site Manager.
478
478
  */
479
- export type BlogAuthor = { name: string; kind: "user" | "byline" | "team"; slug?: string; url?: string | null };
479
+ /**
480
+ * Team authors also carry their title, a line of credentials and profile addresses elsewhere (Site Manager 2026-11),
481
+ * absent from older servers.
482
+ */
483
+ export type BlogAuthor = { name: string; kind: "user" | "byline" | "team"; slug?: string; url?: string | null; jobTitle?: string | null; credentials?: string | null; sameAs?: string[] };
480
484
 
481
485
  /** A post in a list: everything but the body. `url` is null until the site has a blog page pattern in Site Manager. */
482
486
  export type BlogPostSummary = {
@@ -494,11 +498,23 @@ export type BlogPostSummary = {
494
498
  tags: BlogTerm[];
495
499
  };
496
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
+
497
506
  export type BlogPost = BlogPostSummary & {
498
507
  /** Short takeaways, in order, up to seven. */
499
508
  keyPoints: string[];
500
509
  bodyHtml: string;
501
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[];
502
518
  /** The post's search listing from Site Manager, or null while it has no page. */
503
519
  seo: { title: string; description: string | null; canonical: string | null; ogImage: Image | null } | null;
504
520
  };
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const KIT_VERSION = "1.46.0";
1
+ export const KIT_VERSION = "1.48.0";