@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 +4 -1
- package/package.json +1 -1
- package/src/blog.ts +60 -4
- package/src/index.ts +1 -1
- package/src/types.ts +17 -1
- package/src/version.ts +1 -1
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.
|
|
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
|
|
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)
|
|
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
|
-
|
|
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.
|
|
1
|
+
export const KIT_VERSION = "1.48.0";
|