@softure-ai/blog 0.1.7 → 0.1.9
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/CHANGELOG.md +31 -0
- package/README.md +98 -17
- package/dist/cli/run.js +1 -1
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/skill.d.ts.map +1 -1
- package/dist/cli/skill.js +2 -1
- package/dist/cli/skill.js.map +1 -1
- package/dist/db/articles.d.ts.map +1 -1
- package/dist/db/articles.js +7 -3
- package/dist/db/articles.js.map +1 -1
- package/dist/db/history.d.ts +7 -4
- package/dist/db/history.d.ts.map +1 -1
- package/dist/db/history.js +8 -4
- package/dist/db/history.js.map +1 -1
- package/dist/index.d.ts +41 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/messages/en.d.ts +2 -0
- package/dist/messages/en.d.ts.map +1 -1
- package/dist/messages/en.js +2 -0
- package/dist/messages/en.js.map +1 -1
- package/dist/messages/index.d.ts +2 -0
- package/dist/messages/index.d.ts.map +1 -1
- package/dist/messages/pl.d.ts +2 -0
- package/dist/messages/pl.d.ts.map +1 -1
- package/dist/messages/pl.js +2 -0
- package/dist/messages/pl.js.map +1 -1
- package/dist/next/context.d.ts.map +1 -1
- package/dist/next/context.js +1 -0
- package/dist/next/context.js.map +1 -1
- package/dist/next/discovery.js +2 -2
- package/dist/next/discovery.js.map +1 -1
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.d.ts.map +1 -1
- package/dist/next/index.js +2 -0
- package/dist/next/index.js.map +1 -1
- package/dist/next/json-ld.d.ts +13 -0
- package/dist/next/json-ld.d.ts.map +1 -0
- package/dist/next/json-ld.js +50 -0
- package/dist/next/json-ld.js.map +1 -0
- package/dist/next/metadata.d.ts +18 -0
- package/dist/next/metadata.d.ts.map +1 -0
- package/dist/next/metadata.js +86 -0
- package/dist/next/metadata.js.map +1 -0
- package/dist/next/pages.d.ts.map +1 -1
- package/dist/next/pages.js +17 -78
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +53 -7
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +58 -0
- package/dist/options.js.map +1 -1
- package/dist/pages/body.d.ts +1 -1
- package/dist/pages/body.d.ts.map +1 -1
- package/dist/pages/body.js +1 -0
- package/dist/pages/body.js.map +1 -1
- package/dist/pages/index.d.ts +3 -3
- package/dist/pages/index.d.ts.map +1 -1
- package/dist/pages/index.js +2 -2
- package/dist/pages/index.js.map +1 -1
- package/dist/pages/json-ld.d.ts +11 -0
- package/dist/pages/json-ld.d.ts.map +1 -1
- package/dist/pages/json-ld.js +14 -7
- package/dist/pages/json-ld.js.map +1 -1
- package/dist/pages/listing.d.ts +9 -3
- package/dist/pages/listing.d.ts.map +1 -1
- package/dist/pages/listing.js +7 -5
- package/dist/pages/listing.js.map +1 -1
- package/dist/pages/redirects.d.ts +22 -6
- package/dist/pages/redirects.d.ts.map +1 -1
- package/dist/pages/redirects.js +5 -2
- package/dist/pages/redirects.js.map +1 -1
- package/dist/proxy/index.d.ts.map +1 -1
- package/dist/proxy/index.js +8 -1
- package/dist/proxy/index.js.map +1 -1
- package/dist/quality/index.d.ts +1 -1
- package/dist/quality/index.d.ts.map +1 -1
- package/dist/quality/index.js.map +1 -1
- package/dist/quality/link-targets.d.ts.map +1 -1
- package/dist/quality/link-targets.js +6 -5
- package/dist/quality/link-targets.js.map +1 -1
- package/dist/quality/options.d.ts +3 -3
- package/dist/quality/options.d.ts.map +1 -1
- package/dist/quality/options.js +6 -3
- package/dist/quality/options.js.map +1 -1
- package/dist/quality/settings.d.ts +11 -0
- package/dist/quality/settings.d.ts.map +1 -1
- package/dist/quality/settings.js +7 -1
- package/dist/quality/settings.js.map +1 -1
- package/dist/render/index.d.ts +1 -1
- package/dist/render/index.d.ts.map +1 -1
- package/dist/render/index.js +1 -1
- package/dist/render/index.js.map +1 -1
- package/dist/render/render-article.d.ts +8 -0
- package/dist/render/render-article.d.ts.map +1 -1
- package/dist/render/render-article.js +14 -7
- package/dist/render/render-article.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/options.d.ts +9 -1
- package/dist/server/options.d.ts.map +1 -1
- package/dist/server/options.js +7 -1
- package/dist/server/options.js.map +1 -1
- package/dist/ui/blog-listing.js +2 -2
- package/dist/ui/blog-listing.js.map +1 -1
- package/dist/ui/page-context.d.ts +2 -0
- package/dist/ui/page-context.d.ts.map +1 -1
- package/module.json +1 -1
- package/package.json +1 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/skill.ts +2 -1
- package/src/db/articles.ts +11 -6
- package/src/db/history.ts +15 -8
- package/src/index.ts +1 -1
- package/src/messages/en.ts +2 -0
- package/src/messages/pl.ts +2 -0
- package/src/next/context.ts +1 -0
- package/src/next/discovery.ts +2 -2
- package/src/next/index.ts +9 -0
- package/src/next/json-ld.ts +55 -0
- package/src/next/metadata.ts +105 -0
- package/src/next/pages.tsx +17 -83
- package/src/options.ts +68 -1
- package/src/pages/body.ts +2 -1
- package/src/pages/index.ts +6 -1
- package/src/pages/json-ld.ts +29 -8
- package/src/pages/listing.ts +18 -5
- package/src/pages/redirects.ts +27 -3
- package/src/proxy/index.ts +9 -1
- package/src/quality/index.ts +1 -1
- package/src/quality/link-targets.ts +6 -5
- package/src/quality/options.ts +6 -3
- package/src/quality/settings.ts +17 -1
- package/src/render/index.ts +2 -0
- package/src/render/render-article.ts +22 -7
- package/src/server/index.ts +1 -1
- package/src/server/options.ts +15 -2
- package/src/ui/blog-listing.tsx +2 -2
- package/src/ui/page-context.ts +2 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// The structured data of the blog's pages, ready for `<script type="application/ld+json">`: built from
|
|
2
|
+
// what the caller already holds (no database read, no request scope) and serialized so no text can
|
|
3
|
+
// close the tag. The ready-made pages use these; an app with its own page components mounts them as
|
|
4
|
+
//
|
|
5
|
+
// <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: buildArticleJsonLd(config, article) }} />
|
|
6
|
+
import { getSiteUrls, type SoftureConfig } from "@softure-ai/core";
|
|
7
|
+
import type { BlogArticle } from "../contract.js";
|
|
8
|
+
import { getArticleJsonLd, getGlossaryJsonLd, getTermJsonLd, serializeJsonLd, type JsonLdContext } from "../pages/json-ld.js";
|
|
9
|
+
import { getArticleCrumbs, getClusterLabel, getTermCrumbs, sortTerms, type CrumbLabels } from "../pages/listing.js";
|
|
10
|
+
import { getBlogLocaleTags, getBlogOptions } from "../server/options.js";
|
|
11
|
+
import type { BlogPageContext } from "../ui/page-context.js";
|
|
12
|
+
import { getPageContext } from "./context.js";
|
|
13
|
+
|
|
14
|
+
/** The breadcrumb names the pages show: the blog, the glossary and the clusters' display names. */
|
|
15
|
+
export function getCrumbLabels(config: SoftureConfig, context: BlogPageContext = getPageContext(config)): CrumbLabels {
|
|
16
|
+
const clusters = getBlogOptions(config).clusters;
|
|
17
|
+
return {
|
|
18
|
+
blog: context.messages.pages.blogTitle,
|
|
19
|
+
glossary: context.messages.glossary.title,
|
|
20
|
+
cluster: (cluster) => getClusterLabel(cluster, clusters, config.locale),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getJsonLdContext(config: SoftureConfig, context: BlogPageContext): JsonLdContext {
|
|
25
|
+
return {
|
|
26
|
+
urls: getSiteUrls(config),
|
|
27
|
+
routes: context.routes,
|
|
28
|
+
locale: config.locale,
|
|
29
|
+
language: getBlogLocaleTags(config).bcp47,
|
|
30
|
+
ids: getBlogOptions(config).jsonLd.ids,
|
|
31
|
+
timezone: config.timezone,
|
|
32
|
+
brand: context.brand,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** An article's `BlogPosting`, breadcrumbs and FAQ, serialized. */
|
|
37
|
+
export function buildArticleJsonLd(config: SoftureConfig, article: BlogArticle): string {
|
|
38
|
+
const context = getPageContext(config);
|
|
39
|
+
const crumbs = getArticleCrumbs(article, context.routes, getCrumbLabels(config, context), { clusterAnchorPrefix: context.clusterAnchorPrefix });
|
|
40
|
+
return serializeJsonLd(getArticleJsonLd(article, crumbs, getJsonLdContext(config, context)));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A glossary term's `DefinedTerm` and breadcrumbs, serialized. */
|
|
44
|
+
export function buildTermJsonLd(config: SoftureConfig, term: BlogArticle): string {
|
|
45
|
+
const context = getPageContext(config);
|
|
46
|
+
const crumbs = getTermCrumbs(term, context.routes, getCrumbLabels(config, context));
|
|
47
|
+
return serializeJsonLd(getTermJsonLd(term, crumbs, getJsonLdContext(config, context), context.messages.glossary.title));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The glossary's `DefinedTermSet` over the published terms, in the index's order, serialized; `null` without terms. */
|
|
51
|
+
export function buildGlossaryJsonLd(config: SoftureConfig, terms: readonly BlogArticle[]): string | null {
|
|
52
|
+
if (terms.length === 0) return null;
|
|
53
|
+
const context = getPageContext(config);
|
|
54
|
+
return serializeJsonLd(getGlossaryJsonLd(sortTerms(terms, config.locale), getJsonLdContext(config, context), context.messages.glossary.title));
|
|
55
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The metadata of the blog's pages (`<title>`, description, robots, canonical, feed link, Open Graph),
|
|
2
|
+
// built from what the caller already holds: no database read, no request scope. The ready-made pages'
|
|
3
|
+
// `generate*Metadata` read the text and call these; an app with its own page components calls them
|
|
4
|
+
// with the text it read, and can extend the result:
|
|
5
|
+
//
|
|
6
|
+
// export async function generateMetadata({ params }) {
|
|
7
|
+
// const config = getSoftureConfig();
|
|
8
|
+
// const article = await getTextBySlug(config, (await params).slug);
|
|
9
|
+
// if (article?.status !== "published" || article.kind !== "article") return {};
|
|
10
|
+
// return { ...buildArticleMetadata(config, article), keywords: [...] };
|
|
11
|
+
// }
|
|
12
|
+
import { formatMessage, getSiteUrls, type SoftureConfig } from "@softure-ai/core";
|
|
13
|
+
// `next/types.js`, not `next`: the root entry adds Next's globals (a read-only NODE_ENV) to every
|
|
14
|
+
// program that includes this file.
|
|
15
|
+
import type { Metadata } from "next/types.js";
|
|
16
|
+
import type { BlogArticle } from "../contract.js";
|
|
17
|
+
import { getArticleDates } from "../pages/dates.js";
|
|
18
|
+
import { getArticlePath, getTermPath } from "../pages/paths.js";
|
|
19
|
+
import { getBlogLocaleTags } from "../server/options.js";
|
|
20
|
+
import type { BlogPageContext } from "../ui/page-context.js";
|
|
21
|
+
import { getPageContext } from "./context.js";
|
|
22
|
+
|
|
23
|
+
/** Whether a listing has no entry: an empty listing stays out of the index (thin content). */
|
|
24
|
+
export interface ListingMetadataInput {
|
|
25
|
+
readonly isEmpty: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `pattern`: the page kind's title message (`pages.titleWithBrand`, or `glossary.termTitleWithBrand` for a term). */
|
|
29
|
+
function withBrand(title: string, context: BlogPageContext, pattern: string = context.messages.pages.titleWithBrand): string {
|
|
30
|
+
return context.brand === null ? title : formatMessage(pattern, { title, brand: context.brand });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A page's canonical URL: seo's host and trailing-slash rule when the app lists seo, else on `appOrigin`. */
|
|
34
|
+
function getCanonicalUrl(config: SoftureConfig, path: string): string {
|
|
35
|
+
return getSiteUrls(config).getCanonicalUrl(path);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The feed link a reader finds in `<head>` (`<link rel="alternate" type="application/rss+xml">`); a file, so no trailing-slash rule. */
|
|
39
|
+
function getFeedAlternates(config: SoftureConfig, context: BlogPageContext): NonNullable<Metadata["alternates"]>["types"] {
|
|
40
|
+
return { "application/rss+xml": [{ url: `${getSiteUrls(config).origin}${context.routes.rss}`, title: withBrand(context.messages.pages.blogTitle, context) }] };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getStaticMetadata(config: SoftureConfig, context: BlogPageContext, page: { title: string; description: string; path: string; isEmpty?: boolean; hasFeed?: boolean }): Metadata {
|
|
44
|
+
return {
|
|
45
|
+
title: withBrand(page.title, context),
|
|
46
|
+
description: page.description,
|
|
47
|
+
robots: { index: page.isEmpty !== true, follow: true },
|
|
48
|
+
alternates: { canonical: getCanonicalUrl(config, page.path), ...(page.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
49
|
+
openGraph: { type: "website", title: page.title, description: page.description, url: getCanonicalUrl(config, page.path), ...(context.brand === null ? {} : { siteName: context.brand }) },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function getTextMetadata(config: SoftureConfig, context: BlogPageContext, text: BlogArticle, path: string, options: { hasFeed?: boolean; titlePattern?: string } = {}): Metadata {
|
|
54
|
+
const dates = getArticleDates(text, config.timezone);
|
|
55
|
+
const url = getCanonicalUrl(config, path);
|
|
56
|
+
return {
|
|
57
|
+
title: withBrand(text.title, context, options.titlePattern),
|
|
58
|
+
description: text.description,
|
|
59
|
+
robots: { index: true, follow: true },
|
|
60
|
+
alternates: { canonical: url, ...(options.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
61
|
+
openGraph: {
|
|
62
|
+
type: "article",
|
|
63
|
+
title: text.title,
|
|
64
|
+
description: text.description,
|
|
65
|
+
url,
|
|
66
|
+
locale: getBlogLocaleTags(config).openGraph,
|
|
67
|
+
publishedTime: dates.published,
|
|
68
|
+
modifiedTime: dates.updated ?? dates.published,
|
|
69
|
+
...(context.brand === null ? {} : { siteName: context.brand }),
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The listing's metadata, with the feed link. */
|
|
75
|
+
export function buildBlogIndexMetadata(config: SoftureConfig, { isEmpty }: ListingMetadataInput): Metadata {
|
|
76
|
+
const context = getPageContext(config);
|
|
77
|
+
const copy = context.messages.pages;
|
|
78
|
+
return getStaticMetadata(config, context, { title: copy.blogTitle, description: copy.blogDescription, path: context.routes.index, isEmpty, hasFeed: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** An article's metadata, with the feed link. The caller checks the text is a published article. */
|
|
82
|
+
export function buildArticleMetadata(config: SoftureConfig, article: BlogArticle): Metadata {
|
|
83
|
+
const context = getPageContext(config);
|
|
84
|
+
return getTextMetadata(config, context, article, getArticlePath(context.routes, article.slug), { hasFeed: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The glossary index's metadata. */
|
|
88
|
+
export function buildGlossaryIndexMetadata(config: SoftureConfig, { isEmpty }: ListingMetadataInput): Metadata {
|
|
89
|
+
const context = getPageContext(config);
|
|
90
|
+
const copy = context.messages.glossary;
|
|
91
|
+
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.glossary, isEmpty });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A glossary term's metadata, titled by `glossary.termTitleWithBrand`. The caller checks the text is a published term. */
|
|
95
|
+
export function buildTermMetadata(config: SoftureConfig, term: BlogArticle): Metadata {
|
|
96
|
+
const context = getPageContext(config);
|
|
97
|
+
return getTextMetadata(config, context, term, getTermPath(context.routes, term.slug), { titlePattern: context.messages.glossary.termTitleWithBrand });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The method page's metadata. */
|
|
101
|
+
export function buildMethodMetadata(config: SoftureConfig): Metadata {
|
|
102
|
+
const context = getPageContext(config);
|
|
103
|
+
const copy = context.messages.method;
|
|
104
|
+
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.method });
|
|
105
|
+
}
|
package/src/next/pages.tsx
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
// articles and terms render on their first request and are kept for `revalidate` seconds (ISR).
|
|
15
15
|
// 301 and 410 are answered before the page by `@softure-ai/blog/proxy`; anything else that is not a
|
|
16
16
|
// published text of the page's kind is a 404 here.
|
|
17
|
-
|
|
17
|
+
//
|
|
18
|
+
// An app with its own page components keeps the rest: `generate*Metadata` and `BlogArticleOgImage`
|
|
19
|
+
// mount next to its own page, and `build*Metadata` (metadata.ts) and `build*JsonLd` (json-ld.ts) take
|
|
20
|
+
// a text it already read.
|
|
21
|
+
import { getSiteUrls, type SoftureConfig } from "@softure-ai/core";
|
|
18
22
|
import { getSoftureConfig } from "@softure-ai/core/next";
|
|
19
23
|
// `next/types.js`, not `next`: the root entry adds Next's globals (a read-only NODE_ENV) to every
|
|
20
24
|
// program that includes this file.
|
|
@@ -25,9 +29,7 @@ import type { BlogArticle } from "../contract.js";
|
|
|
25
29
|
import { getRelatedArticles } from "../discovery/related.js";
|
|
26
30
|
import { findArticlesLinkingTerm, renderPageBody, type RenderPageBodyOptions } from "../pages/body.js";
|
|
27
31
|
import { getArticleDates } from "../pages/dates.js";
|
|
28
|
-
import {
|
|
29
|
-
import { getArticleCrumbs, getClusterLabel, getTermCrumbs, groupByCluster, sortTerms, type CrumbLabels } from "../pages/listing.js";
|
|
30
|
-
import { getArticlePath, getTermPath } from "../pages/paths.js";
|
|
32
|
+
import { getArticleCrumbs, groupByCluster, getTermCrumbs, sortTerms } from "../pages/listing.js";
|
|
31
33
|
import { toGlossary } from "../render/glossary.js";
|
|
32
34
|
import { getBlogOptions } from "../server/options.js";
|
|
33
35
|
import { BlogArticleView } from "../ui/blog-article.js";
|
|
@@ -37,6 +39,8 @@ import { BlogMethodView } from "../ui/blog-method.js";
|
|
|
37
39
|
import type { BlogPageContext } from "../ui/page-context.js";
|
|
38
40
|
import { getPageContext } from "./context.js";
|
|
39
41
|
import { getPublishedArticles, getPublishedTerms, getTextBySlug } from "./data.js";
|
|
42
|
+
import { buildArticleJsonLd, buildGlossaryJsonLd, buildTermJsonLd, getCrumbLabels } from "./json-ld.js";
|
|
43
|
+
import { buildArticleMetadata, buildBlogIndexMetadata, buildGlossaryIndexMetadata, buildMethodMetadata, buildTermMetadata } from "./metadata.js";
|
|
40
44
|
|
|
41
45
|
type SlugParams = Promise<{ readonly slug: string }>;
|
|
42
46
|
|
|
@@ -67,76 +71,15 @@ function isPublished(text: BlogArticle | null, kind: BlogArticle["kind"]): text
|
|
|
67
71
|
return text !== null && text.status === "published" && text.kind === kind;
|
|
68
72
|
}
|
|
69
73
|
|
|
70
|
-
function getCrumbLabels(config: SoftureConfig, context: BlogPageContext): CrumbLabels {
|
|
71
|
-
const clusters = getBlogOptions(config).clusters;
|
|
72
|
-
return {
|
|
73
|
-
blog: context.messages.pages.blogTitle,
|
|
74
|
-
glossary: context.messages.glossary.title,
|
|
75
|
-
cluster: (cluster) => getClusterLabel(cluster, clusters, config.locale),
|
|
76
|
-
};
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function getJsonLdContext(config: SoftureConfig, context: BlogPageContext): JsonLdContext {
|
|
80
|
-
return { urls: getSiteUrls(config), routes: context.routes, locale: config.locale, timezone: config.timezone, brand: context.brand };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
74
|
function getBodyOptions(config: SoftureConfig, context: BlogPageContext, terms: readonly BlogArticle[]): RenderPageBodyOptions {
|
|
84
75
|
const origins = [config.appOrigin, getSiteUrls(config).origin];
|
|
85
76
|
return { glossary: toGlossary(terms), routes: context.routes, options: getBlogOptions(config), origins, messages: context.messages };
|
|
86
77
|
}
|
|
87
78
|
|
|
88
|
-
function withBrand(title: string, context: BlogPageContext): string {
|
|
89
|
-
return context.brand === null ? title : formatMessage(context.messages.pages.titleWithBrand, { title, brand: context.brand });
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** A page's canonical URL: seo's host and trailing-slash rule when the app lists seo, else on `appOrigin`. */
|
|
93
|
-
function getCanonicalUrl(config: SoftureConfig, path: string): string {
|
|
94
|
-
return getSiteUrls(config).getCanonicalUrl(path);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** The feed link a reader finds in `<head>` (`<link rel="alternate" type="application/rss+xml">`); a file, so no trailing-slash rule. */
|
|
98
|
-
function getFeedAlternates(config: SoftureConfig, context: BlogPageContext): NonNullable<Metadata["alternates"]>["types"] {
|
|
99
|
-
return { "application/rss+xml": [{ url: `${getSiteUrls(config).origin}${context.routes.rss}`, title: withBrand(context.messages.pages.blogTitle, context) }] };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Metadata of a page with a fixed path; an empty listing stays out of the index (thin content). */
|
|
103
|
-
function getStaticMetadata(config: SoftureConfig, context: BlogPageContext, page: { title: string; description: string; path: string; isEmpty?: boolean; hasFeed?: boolean }): Metadata {
|
|
104
|
-
return {
|
|
105
|
-
title: withBrand(page.title, context),
|
|
106
|
-
description: page.description,
|
|
107
|
-
robots: { index: page.isEmpty !== true, follow: true },
|
|
108
|
-
alternates: { canonical: getCanonicalUrl(config, page.path), ...(page.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
109
|
-
openGraph: { type: "website", title: page.title, description: page.description, url: getCanonicalUrl(config, page.path), ...(context.brand === null ? {} : { siteName: context.brand }) },
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
function getTextMetadata(config: SoftureConfig, context: BlogPageContext, text: BlogArticle, path: string, options: { hasFeed?: boolean } = {}): Metadata {
|
|
114
|
-
const dates = getArticleDates(text, config.timezone);
|
|
115
|
-
const url = getCanonicalUrl(config, path);
|
|
116
|
-
return {
|
|
117
|
-
title: withBrand(text.title, context),
|
|
118
|
-
description: text.description,
|
|
119
|
-
robots: { index: true, follow: true },
|
|
120
|
-
alternates: { canonical: url, ...(options.hasFeed === true ? { types: getFeedAlternates(config, context) } : {}) },
|
|
121
|
-
openGraph: {
|
|
122
|
-
type: "article",
|
|
123
|
-
title: text.title,
|
|
124
|
-
description: text.description,
|
|
125
|
-
url,
|
|
126
|
-
locale: config.locale,
|
|
127
|
-
publishedTime: dates.published,
|
|
128
|
-
modifiedTime: dates.updated ?? dates.published,
|
|
129
|
-
...(context.brand === null ? {} : { siteName: context.brand }),
|
|
130
|
-
},
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
79
|
export async function generateBlogIndexMetadata(): Promise<Metadata> {
|
|
135
80
|
const config = getSoftureConfig();
|
|
136
|
-
const context = getPageContext(config);
|
|
137
81
|
const articles = await getPublishedArticles(config);
|
|
138
|
-
|
|
139
|
-
return getStaticMetadata(config, context, { title: copy.blogTitle, description: copy.blogDescription, path: context.routes.index, isEmpty: articles.length === 0, hasFeed: true });
|
|
82
|
+
return buildBlogIndexMetadata(config, { isEmpty: articles.length === 0 });
|
|
140
83
|
}
|
|
141
84
|
|
|
142
85
|
/** The listing: cards grouped by cluster, the pillar first. Mount with `dynamic = "force-dynamic"`. */
|
|
@@ -153,9 +96,7 @@ export async function generateArticleMetadata({ params }: { readonly params: Slu
|
|
|
153
96
|
const config = getSoftureConfig();
|
|
154
97
|
const { slug } = await params;
|
|
155
98
|
const article = await getTextBySlug(config, slug);
|
|
156
|
-
|
|
157
|
-
const context = getPageContext(config);
|
|
158
|
-
return getTextMetadata(config, context, article, getArticlePath(context.routes, article.slug), { hasFeed: true });
|
|
99
|
+
return isPublished(article, "article") ? buildArticleMetadata(config, article) : {};
|
|
159
100
|
}
|
|
160
101
|
|
|
161
102
|
/** An article with "read next" under it. Mount with `revalidate` and `generateBlogStaticParams`. */
|
|
@@ -167,8 +108,8 @@ export async function BlogArticlePage({ params, cta, afterArticle }: BlogArticle
|
|
|
167
108
|
const context = getPageContext(config);
|
|
168
109
|
const [terms, published] = await Promise.all([getPublishedTerms(config), getPublishedArticles(config)]);
|
|
169
110
|
const body = renderPageBody<ReactNode>(article, getBodyOptions(config, context, terms));
|
|
170
|
-
const crumbs = getArticleCrumbs(article, context.routes, getCrumbLabels(config, context));
|
|
171
|
-
const jsonLd =
|
|
111
|
+
const crumbs = getArticleCrumbs(article, context.routes, getCrumbLabels(config, context), { clusterAnchorPrefix: context.clusterAnchorPrefix });
|
|
112
|
+
const jsonLd = buildArticleJsonLd(config, article);
|
|
172
113
|
return (
|
|
173
114
|
<BlogArticleView
|
|
174
115
|
context={context}
|
|
@@ -186,10 +127,8 @@ export async function BlogArticlePage({ params, cta, afterArticle }: BlogArticle
|
|
|
186
127
|
|
|
187
128
|
export async function generateGlossaryIndexMetadata(): Promise<Metadata> {
|
|
188
129
|
const config = getSoftureConfig();
|
|
189
|
-
const context = getPageContext(config);
|
|
190
130
|
const terms = await getPublishedTerms(config);
|
|
191
|
-
|
|
192
|
-
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.glossary, isEmpty: terms.length === 0 });
|
|
131
|
+
return buildGlossaryIndexMetadata(config, { isEmpty: terms.length === 0 });
|
|
193
132
|
}
|
|
194
133
|
|
|
195
134
|
/** The glossary index. Mount with `dynamic = "force-dynamic"`. */
|
|
@@ -197,7 +136,7 @@ export async function GlossaryIndexPage() {
|
|
|
197
136
|
const config = getSoftureConfig();
|
|
198
137
|
const context = getPageContext(config);
|
|
199
138
|
const terms = sortTerms(await getPublishedTerms(config), config.locale);
|
|
200
|
-
const jsonLd =
|
|
139
|
+
const jsonLd = buildGlossaryJsonLd(config, terms);
|
|
201
140
|
return <GlossaryIndexView context={context} terms={terms} jsonLd={jsonLd} />;
|
|
202
141
|
}
|
|
203
142
|
|
|
@@ -205,9 +144,7 @@ export async function generateTermMetadata({ params }: { readonly params: SlugPa
|
|
|
205
144
|
const config = getSoftureConfig();
|
|
206
145
|
const { slug } = await params;
|
|
207
146
|
const term = await getTextBySlug(config, slug);
|
|
208
|
-
|
|
209
|
-
const context = getPageContext(config);
|
|
210
|
-
return getTextMetadata(config, context, term, getTermPath(context.routes, term.slug));
|
|
147
|
+
return isPublished(term, "term") ? buildTermMetadata(config, term) : {};
|
|
211
148
|
}
|
|
212
149
|
|
|
213
150
|
/** A glossary term with the articles that expand on it. Mount with `revalidate` and `generateBlogStaticParams`. */
|
|
@@ -220,7 +157,7 @@ export async function GlossaryTermPage({ params, cta }: GlossaryTermPageProps) {
|
|
|
220
157
|
const [terms, articles] = await Promise.all([getPublishedTerms(config), getPublishedArticles(config)]);
|
|
221
158
|
const bodyOptions = getBodyOptions(config, context, terms);
|
|
222
159
|
const crumbs = getTermCrumbs(term, context.routes, getCrumbLabels(config, context));
|
|
223
|
-
const jsonLd =
|
|
160
|
+
const jsonLd = buildTermJsonLd(config, term);
|
|
224
161
|
return (
|
|
225
162
|
<GlossaryTermView
|
|
226
163
|
context={context}
|
|
@@ -236,10 +173,7 @@ export async function GlossaryTermPage({ params, cta }: GlossaryTermPageProps) {
|
|
|
236
173
|
}
|
|
237
174
|
|
|
238
175
|
export function generateMethodMetadata(): Metadata {
|
|
239
|
-
|
|
240
|
-
const context = getPageContext(config);
|
|
241
|
-
const copy = context.messages.method;
|
|
242
|
-
return getStaticMetadata(config, context, { title: copy.title, description: copy.description, path: context.routes.method });
|
|
176
|
+
return buildMethodMetadata(getSoftureConfig());
|
|
243
177
|
}
|
|
244
178
|
|
|
245
179
|
/** "How our texts are made". Mount it with `blog({ methodPage: true })`; without that it is a 404. */
|
package/src/options.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The options an app passes to `blog({ ... })` in softure.config.ts, parsed at startup.
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { qualitySettingSchema } from "./quality/options.js";
|
|
4
|
+
import type { GonePageRenderInput } from "./pages/redirects.js";
|
|
4
5
|
import type { ArticleImagePolicy } from "./render/images.js";
|
|
5
|
-
import type
|
|
6
|
+
import { EXTERNAL_LINK_MARKERS, type BlockPlugin } from "./render/render-article.js";
|
|
6
7
|
|
|
7
8
|
/** Where the app keeps its article files unless a command names a path. */
|
|
8
9
|
export const DEFAULT_CONTENT_DIR = "content/blog";
|
|
@@ -162,6 +163,23 @@ const skillSectionSchema = z.strictObject({
|
|
|
162
163
|
.refine((body) => !hasTopHeading(body), "must not hold a # or ## heading; use ### and deeper (the title is the section's ## heading)"),
|
|
163
164
|
});
|
|
164
165
|
|
|
166
|
+
/** A 410 link: a path from the site root or an https URL, never another scheme. */
|
|
167
|
+
const goneHrefSchema = z
|
|
168
|
+
.string()
|
|
169
|
+
.trim()
|
|
170
|
+
.refine((href) => (href.startsWith("/") && !href.startsWith("//")) || (href.startsWith("https://") && URL.canParse(href)), "must be a path from the site root or an https URL, e.g. /calculator");
|
|
171
|
+
|
|
172
|
+
const gonePageSchema = z.strictObject({
|
|
173
|
+
/** Further ways on, listed under the link to the listing. */
|
|
174
|
+
links: z.array(z.strictObject({ href: goneHrefSchema, label: localizedTextSchema })).max(5).default([]),
|
|
175
|
+
/**
|
|
176
|
+
* `(input) => html`: the whole body of the 410, written by the app (its own HTML, trusted like a
|
|
177
|
+
* block plugin's; the proxy still answers 410 with `text/html`). `input.links` holds the labels in
|
|
178
|
+
* the app's locale.
|
|
179
|
+
*/
|
|
180
|
+
render: z.custom<(input: GonePageRenderInput) => string>((value) => typeof value === "function", "must be a function: (input) => html").optional(),
|
|
181
|
+
});
|
|
182
|
+
|
|
165
183
|
const skillSchema = z.strictObject({
|
|
166
184
|
/** The app's own sections (its numbers, block plugins, fields), written to `references/app.md` of the skill. */
|
|
167
185
|
sections: z.array(skillSectionSchema).superRefine((sections, ctx) => {
|
|
@@ -182,6 +200,42 @@ const imagePolicySchema = z.strictObject({
|
|
|
182
200
|
dimensions: z.custom<ArticleImagePolicy["dimensions"]>((value) => typeof value === "function", "must be a function: (src) => ({ width, height }) or null"),
|
|
183
201
|
});
|
|
184
202
|
|
|
203
|
+
/** A URL fragment or HTML id part, without `#`: a JSON-LD node id or the cluster anchor prefix. */
|
|
204
|
+
const fragmentSchema = z
|
|
205
|
+
.string()
|
|
206
|
+
.max(40)
|
|
207
|
+
.regex(/^[A-Za-z][A-Za-z0-9_-]*$/, "must be a letter followed by letters, digits, - or _, without #, e.g. article");
|
|
208
|
+
|
|
209
|
+
const jsonLdSchema = z.strictObject({
|
|
210
|
+
/** The `@id` fragments of the nodes: `<url>#<article>`, `<url>#<term>`, `<glossary url>#<glossary>`. */
|
|
211
|
+
ids: z
|
|
212
|
+
.strictObject({ article: fragmentSchema.default("article"), term: fragmentSchema.default("term"), glossary: fragmentSchema.default("glossary") })
|
|
213
|
+
.default({ article: "article", term: "term", glossary: "glossary" }),
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const anchorsSchema = z.strictObject({
|
|
217
|
+
/** A cluster's section on the listing is `<cluster>-<key>`, the breadcrumb of its articles points there. */
|
|
218
|
+
cluster: fragmentSchema.default("cluster"),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** The Open Graph locale of a language when the app sets none: `og:locale` is `language_TERRITORY`. */
|
|
222
|
+
export const DEFAULT_OPEN_GRAPH_LOCALES = { en: "en_US", pl: "pl_PL" } as const;
|
|
223
|
+
|
|
224
|
+
const localeTagsSchema = z.strictObject({
|
|
225
|
+
/** The content's language as a BCP-47 tag: JSON-LD `inLanguage` and the feed's `<language>`; the bare code by default. */
|
|
226
|
+
bcp47: z
|
|
227
|
+
.string()
|
|
228
|
+
.regex(/^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$/, "must be a BCP-47 tag, e.g. pl-PL")
|
|
229
|
+
.optional(),
|
|
230
|
+
/** `og:locale` (`language_TERRITORY`); `en_US` or `pl_PL` by default. */
|
|
231
|
+
openGraph: z
|
|
232
|
+
.string()
|
|
233
|
+
.regex(/^[a-z]{2,3}_[A-Z]{2}$/, "must be an Open Graph locale (language_TERRITORY), e.g. pl_PL")
|
|
234
|
+
.optional(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
export type BlogLocaleTagsInput = z.output<typeof localeTagsSchema>;
|
|
238
|
+
|
|
185
239
|
export const blogOptionsSchema = z
|
|
186
240
|
.strictObject({
|
|
187
241
|
/** The folder with the article files, relative to the app's root. */
|
|
@@ -204,11 +258,24 @@ export const blogOptionsSchema = z
|
|
|
204
258
|
blocks: z.array(z.custom<BlockPlugin>(isBlockPlugin, "must be a block plugin: { type: \"chart\", render(block) }")).default([]),
|
|
205
259
|
/** Hosts besides those of `appOrigin` and the canonical site origin (`getSiteUrls`) whose links are not marked external (subdomains included). */
|
|
206
260
|
siteHosts: z.array(z.string().regex(HOSTNAME, "must be a host name, e.g. example.com")).default([]),
|
|
261
|
+
/**
|
|
262
|
+
* What the pages append to an external link (`renderArticle({ externalMarker })`): the arrow and a
|
|
263
|
+
* visually hidden "opens in a new tab", the hidden words only, or nothing.
|
|
264
|
+
*/
|
|
265
|
+
externalLinkMarker: z.enum(EXTERNAL_LINK_MARKERS).default("icon-and-text"),
|
|
207
266
|
/**
|
|
208
267
|
* Which images article bodies may show (`renderArticle({ images })`), used by the pages and the
|
|
209
268
|
* quality gate; without it every image renders as its alt text and the gate refuses it.
|
|
210
269
|
*/
|
|
211
270
|
images: imagePolicySchema.optional(),
|
|
271
|
+
/** The JSON-LD node ids; an app that already published other fragments keeps them here. */
|
|
272
|
+
jsonLd: jsonLdSchema.default({ ids: { article: "article", term: "term", glossary: "glossary" } }),
|
|
273
|
+
/** The anchors the pages write; an app whose listing already used another prefix keeps it here. */
|
|
274
|
+
anchors: anchorsSchema.default({ cluster: "cluster" }),
|
|
275
|
+
/** The language tags per app locale: BCP-47 for the content, Open Graph for `og:locale`. */
|
|
276
|
+
locales: z.strictObject({ en: localeTagsSchema.optional(), pl: localeTagsSchema.optional() }).default({}),
|
|
277
|
+
/** The page a withdrawn text answers with (410): extra links, or the app's own body. */
|
|
278
|
+
gonePage: gonePageSchema.default({ links: [] }),
|
|
212
279
|
/** How long the listing and glossary cache their reads; keep it equal to the pages' `revalidate`. */
|
|
213
280
|
revalidateSeconds: z.number().int().min(1).default(DEFAULT_REVALIDATE_SECONDS),
|
|
214
281
|
/** The text quality gate (`softure-blog check`, and every publish); `false` turns it off. */
|
package/src/pages/body.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { getTermPath, type BlogRoutes } from "./paths.js";
|
|
|
11
11
|
export interface RenderPageBodyOptions {
|
|
12
12
|
readonly glossary: readonly GlossaryTerm[];
|
|
13
13
|
readonly routes: BlogRoutes;
|
|
14
|
-
readonly options: Pick<BlogOptions, "blocks" | "images" | "siteHosts"
|
|
14
|
+
readonly options: Pick<BlogOptions, "blocks" | "images" | "siteHosts"> & Partial<Pick<BlogOptions, "externalLinkMarker">>;
|
|
15
15
|
/** The app's own origins (`appOrigin` and the canonical site origin); their hosts are the site's (links to them are not external). */
|
|
16
16
|
readonly origins: readonly string[];
|
|
17
17
|
readonly messages: BlogMessages;
|
|
@@ -28,6 +28,7 @@ export function renderPageBody<TNode = unknown>(text: BlogArticle, input: Render
|
|
|
28
28
|
// app's plugins return.
|
|
29
29
|
blocks: input.options.blocks as readonly BlockPlugin<TNode>[],
|
|
30
30
|
article: { currentAsOf: text.currentAsOf, fields: text.fields },
|
|
31
|
+
...(input.options.externalLinkMarker === undefined ? {} : { externalMarker: input.options.externalLinkMarker }),
|
|
31
32
|
messages: input.messages.render,
|
|
32
33
|
});
|
|
33
34
|
}
|
package/src/pages/index.ts
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// 301/410 decisions. The components (`../ui/`) and the Next adapter (`../next/`) render what it returns.
|
|
3
3
|
export { findArticlesLinkingTerm, renderPageBody, type RenderPageBodyOptions } from "./body.js";
|
|
4
4
|
export { formatDay, getArticleDates, getDayInZone, type ArticleDates } from "./dates.js";
|
|
5
|
-
export { getArticleImageUrl, getArticleJsonLd, getGlossaryJsonLd, getTermJsonLd, serializeJsonLd, type JsonLdContext } from "./json-ld.js";
|
|
5
|
+
export { DEFAULT_JSON_LD_IDS, getArticleImageUrl, getArticleJsonLd, getGlossaryJsonLd, getTermJsonLd, serializeJsonLd, type JsonLdContext, type JsonLdIds } from "./json-ld.js";
|
|
6
6
|
export {
|
|
7
|
+
DEFAULT_CLUSTER_ANCHOR_PREFIX,
|
|
7
8
|
getArticleCrumbs,
|
|
8
9
|
getClusterAnchor,
|
|
9
10
|
getClusterLabel,
|
|
@@ -11,6 +12,7 @@ export {
|
|
|
11
12
|
groupByCluster,
|
|
12
13
|
sortTerms,
|
|
13
14
|
splitClusterLead,
|
|
15
|
+
type ArticleCrumbOptions,
|
|
14
16
|
type ClusterGroup,
|
|
15
17
|
type Crumb,
|
|
16
18
|
type CrumbLabels,
|
|
@@ -34,4 +36,7 @@ export {
|
|
|
34
36
|
type BlogPathLookup,
|
|
35
37
|
type CachedDeciderOptions,
|
|
36
38
|
type GonePageCopy,
|
|
39
|
+
type GonePageLink,
|
|
40
|
+
type GonePageOptions,
|
|
41
|
+
type GonePageRenderInput,
|
|
37
42
|
} from "./redirects.js";
|
package/src/pages/json-ld.ts
CHANGED
|
@@ -12,13 +12,34 @@ export interface JsonLdContext {
|
|
|
12
12
|
readonly urls: SiteUrls;
|
|
13
13
|
readonly routes: BlogRoutes;
|
|
14
14
|
readonly locale: Locale;
|
|
15
|
+
/** The BCP-47 tag written as `inLanguage` (`getBlogLocaleTags`); `locale` when left out. */
|
|
16
|
+
readonly language?: string;
|
|
17
|
+
/** The `@id` fragments; `article`, `term` and `glossary` when left out. */
|
|
18
|
+
readonly ids?: JsonLdIds;
|
|
15
19
|
readonly timezone: string;
|
|
16
20
|
/** The brand as author and publisher; `null` leaves them out. */
|
|
17
21
|
readonly brand: string | null;
|
|
18
22
|
}
|
|
19
23
|
|
|
24
|
+
/** The fragments after `#` in the nodes' `@id`s. */
|
|
25
|
+
export interface JsonLdIds {
|
|
26
|
+
readonly article: string;
|
|
27
|
+
readonly term: string;
|
|
28
|
+
readonly glossary: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_JSON_LD_IDS: JsonLdIds = { article: "article", term: "term", glossary: "glossary" };
|
|
32
|
+
|
|
20
33
|
type JsonLd = Record<string, unknown>;
|
|
21
34
|
|
|
35
|
+
function getIds(ctx: Pick<JsonLdContext, "ids">): JsonLdIds {
|
|
36
|
+
return ctx.ids ?? DEFAULT_JSON_LD_IDS;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getLanguage(ctx: Pick<JsonLdContext, "language" | "locale">): string {
|
|
40
|
+
return ctx.language ?? ctx.locale;
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
/** JSON for a `<script type="application/ld+json">`: no text in it can close the tag. */
|
|
23
44
|
export function serializeJsonLd(data: unknown): string {
|
|
24
45
|
return JSON.stringify(data).replace(/</g, "\\u003c");
|
|
@@ -57,12 +78,12 @@ export function getArticleJsonLd(article: BlogArticle, crumbs: readonly Crumb[],
|
|
|
57
78
|
const graph: JsonLd[] = [
|
|
58
79
|
{
|
|
59
80
|
"@type": "BlogPosting",
|
|
60
|
-
"@id": `${url}
|
|
81
|
+
"@id": `${url}#${getIds(ctx).article}`,
|
|
61
82
|
mainEntityOfPage: url,
|
|
62
83
|
url,
|
|
63
84
|
headline: article.title,
|
|
64
85
|
description: article.description,
|
|
65
|
-
inLanguage: ctx
|
|
86
|
+
inLanguage: getLanguage(ctx),
|
|
66
87
|
datePublished: dates.published,
|
|
67
88
|
dateModified: dates.updated ?? dates.published,
|
|
68
89
|
...(brand === null ? {} : { author: brand, publisher: brand }),
|
|
@@ -80,8 +101,8 @@ export function getArticleJsonLd(article: BlogArticle, crumbs: readonly Crumb[],
|
|
|
80
101
|
return { "@context": "https://schema.org", "@graph": graph };
|
|
81
102
|
}
|
|
82
103
|
|
|
83
|
-
function getTermSetReference(ctx: Pick<JsonLdContext, "urls" | "routes">, glossaryTitle: string): JsonLd {
|
|
84
|
-
return { "@type": "DefinedTermSet", "@id": `${ctx.urls.getCanonicalUrl(ctx.routes.glossary)}
|
|
104
|
+
function getTermSetReference(ctx: Pick<JsonLdContext, "urls" | "routes" | "ids">, glossaryTitle: string): JsonLd {
|
|
105
|
+
return { "@type": "DefinedTermSet", "@id": `${ctx.urls.getCanonicalUrl(ctx.routes.glossary)}#${getIds(ctx).glossary}`, name: glossaryTitle };
|
|
85
106
|
}
|
|
86
107
|
|
|
87
108
|
/** A term: `DefinedTerm` in the glossary's `DefinedTermSet`, and its `BreadcrumbList`. */
|
|
@@ -92,11 +113,11 @@ export function getTermJsonLd(term: BlogArticle, crumbs: readonly Crumb[], ctx:
|
|
|
92
113
|
"@graph": [
|
|
93
114
|
{
|
|
94
115
|
"@type": "DefinedTerm",
|
|
95
|
-
"@id": `${url}
|
|
116
|
+
"@id": `${url}#${getIds(ctx).term}`,
|
|
96
117
|
url,
|
|
97
118
|
name: term.title,
|
|
98
119
|
description: term.description,
|
|
99
|
-
inLanguage: ctx
|
|
120
|
+
inLanguage: getLanguage(ctx),
|
|
100
121
|
...(term.termForms.length > 0 ? { alternateName: term.termForms } : {}),
|
|
101
122
|
inDefinedTermSet: getTermSetReference(ctx, glossaryTitle),
|
|
102
123
|
},
|
|
@@ -111,10 +132,10 @@ export function getGlossaryJsonLd(terms: readonly BlogArticle[], ctx: JsonLdCont
|
|
|
111
132
|
"@context": "https://schema.org",
|
|
112
133
|
...getTermSetReference(ctx, glossaryTitle),
|
|
113
134
|
url: ctx.urls.getCanonicalUrl(ctx.routes.glossary),
|
|
114
|
-
inLanguage: ctx
|
|
135
|
+
inLanguage: getLanguage(ctx),
|
|
115
136
|
hasDefinedTerm: terms.map((term) => {
|
|
116
137
|
const url = ctx.urls.getCanonicalUrl(getTermPath(ctx.routes, term.slug));
|
|
117
|
-
return { "@type": "DefinedTerm", "@id": `${url}
|
|
138
|
+
return { "@type": "DefinedTerm", "@id": `${url}#${getIds(ctx).term}`, name: term.title, description: term.description, url };
|
|
118
139
|
}),
|
|
119
140
|
};
|
|
120
141
|
}
|
package/src/pages/listing.ts
CHANGED
|
@@ -23,9 +23,12 @@ export function getClusterLabel(cluster: string, labels: Readonly<Record<string,
|
|
|
23
23
|
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
/** The anchor
|
|
27
|
-
export
|
|
28
|
-
|
|
26
|
+
/** The cluster anchor prefix when the app sets none (`blog({ anchors: { cluster } })`). */
|
|
27
|
+
export const DEFAULT_CLUSTER_ANCHOR_PREFIX = "cluster";
|
|
28
|
+
|
|
29
|
+
/** The anchor of a cluster's section on the listing (`<prefix>-<cluster>`): the target of the article's middle crumb. */
|
|
30
|
+
export function getClusterAnchor(cluster: string, prefix: string = DEFAULT_CLUSTER_ANCHOR_PREFIX): string {
|
|
31
|
+
return `${prefix}-${cluster}`;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
/** The pillar leads its group; the rest keep their order. Without a pillar there is no lead. */
|
|
@@ -75,11 +78,21 @@ export interface CrumbLabels {
|
|
|
75
78
|
readonly cluster: (cluster: string) => string;
|
|
76
79
|
}
|
|
77
80
|
|
|
81
|
+
export interface ArticleCrumbOptions {
|
|
82
|
+
/** The listing's cluster anchor prefix; `cluster` when left out. */
|
|
83
|
+
readonly clusterAnchorPrefix?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
78
86
|
/** Blog › cluster › title; without a cluster, Blog › title. */
|
|
79
|
-
export function getArticleCrumbs(
|
|
87
|
+
export function getArticleCrumbs(
|
|
88
|
+
article: Pick<BlogArticle, "slug" | "title" | "cluster">,
|
|
89
|
+
routes: BlogRoutes,
|
|
90
|
+
labels: CrumbLabels,
|
|
91
|
+
options: ArticleCrumbOptions = {},
|
|
92
|
+
): Crumb[] {
|
|
80
93
|
const crumbs: Crumb[] = [{ name: labels.blog, path: routes.index }];
|
|
81
94
|
if (article.cluster !== null) {
|
|
82
|
-
crumbs.push({ name: labels.cluster(article.cluster), path: `${routes.index}#${getClusterAnchor(article.cluster)}` });
|
|
95
|
+
crumbs.push({ name: labels.cluster(article.cluster), path: `${routes.index}#${getClusterAnchor(article.cluster, options.clusterAnchorPrefix)}` });
|
|
83
96
|
}
|
|
84
97
|
crumbs.push({ name: article.title, path: getArticlePath(routes, article.slug) });
|
|
85
98
|
return crumbs;
|