@softure-ai/blog 0.1.8 → 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 +13 -0
- package/README.md +21 -0
- package/dist/index.d.ts +21 -0
- 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/json-ld.d.ts.map +1 -1
- package/dist/next/json-ld.js +11 -3
- package/dist/next/json-ld.js.map +1 -1
- package/dist/next/metadata.d.ts +1 -1
- package/dist/next/metadata.d.ts.map +1 -1
- package/dist/next/metadata.js +8 -6
- package/dist/next/metadata.js.map +1 -1
- package/dist/next/pages.js +1 -1
- package/dist/next/pages.js.map +1 -1
- package/dist/options.d.ts +30 -0
- package/dist/options.d.ts.map +1 -1
- package/dist/options.js +35 -0
- package/dist/options.js.map +1 -1
- package/dist/pages/index.d.ts +2 -2
- 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/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 +6 -0
- 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/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/json-ld.ts +11 -3
- package/src/next/metadata.ts +9 -7
- package/src/next/pages.tsx +1 -1
- package/src/options.ts +42 -0
- package/src/pages/index.ts +3 -1
- package/src/pages/json-ld.ts +29 -8
- package/src/pages/listing.ts +18 -5
- package/src/server/index.ts +1 -1
- package/src/server/options.ts +14 -1
- package/src/ui/blog-listing.tsx +2 -2
- package/src/ui/page-context.ts +2 -0
package/src/options.ts
CHANGED
|
@@ -200,6 +200,42 @@ const imagePolicySchema = z.strictObject({
|
|
|
200
200
|
dimensions: z.custom<ArticleImagePolicy["dimensions"]>((value) => typeof value === "function", "must be a function: (src) => ({ width, height }) or null"),
|
|
201
201
|
});
|
|
202
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
|
+
|
|
203
239
|
export const blogOptionsSchema = z
|
|
204
240
|
.strictObject({
|
|
205
241
|
/** The folder with the article files, relative to the app's root. */
|
|
@@ -232,6 +268,12 @@ export const blogOptionsSchema = z
|
|
|
232
268
|
* quality gate; without it every image renders as its alt text and the gate refuses it.
|
|
233
269
|
*/
|
|
234
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({}),
|
|
235
277
|
/** The page a withdrawn text answers with (410): extra links, or the app's own body. */
|
|
236
278
|
gonePage: gonePageSchema.default({ links: [] }),
|
|
237
279
|
/** How long the listing and glossary cache their reads; keep it equal to the pages' `revalidate`. */
|
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,
|
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;
|
package/src/server/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export {
|
|
|
23
23
|
type RunBlogPublishOptions,
|
|
24
24
|
} from "../db/publish-run.js";
|
|
25
25
|
export { checkArticlesTable } from "./health.js";
|
|
26
|
-
export { getBlogMessages, getBlogOptions, getBlogRefreshPath, getBlogReservedSlugs, getBlogRoutes, getQualitySettings } from "./options.js";
|
|
26
|
+
export { getBlogLocaleTags, getBlogMessages, getBlogOptions, getBlogRefreshPath, getBlogReservedSlugs, getBlogRoutes, getQualitySettings, type BlogLocaleTags } from "./options.js";
|
|
27
27
|
export * from "../discovery/index.js";
|
|
28
28
|
export * from "../pages/index.js";
|
|
29
29
|
export * from "../quality/index.js";
|
package/src/server/options.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The blog options, routes and copy of the running app, read from the configuration.
|
|
2
2
|
import { getModule, getSiteUrls, type AnySoftureModule, type SoftureConfig } from "@softure-ai/core";
|
|
3
3
|
import type { BlogMessages } from "../messages/index.js";
|
|
4
|
-
import type
|
|
4
|
+
import { DEFAULT_OPEN_GRAPH_LOCALES, type BlogOptions } from "../options.js";
|
|
5
5
|
import { getReservedSlugs, normalizeRoute, type BlogRoutes } from "../pages/paths.js";
|
|
6
6
|
import { resolveQualitySettings, type QualitySettings } from "../quality/settings.js";
|
|
7
7
|
|
|
@@ -39,6 +39,19 @@ export function getBlogRoutes(config: SoftureConfig): BlogRoutes {
|
|
|
39
39
|
return { index: read("index"), glossary: read("glossary"), method: read("method"), rss: read("rss") };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
export interface BlogLocaleTags {
|
|
43
|
+
/** BCP-47: JSON-LD `inLanguage` and the feed's `<language>`. */
|
|
44
|
+
readonly bcp47: string;
|
|
45
|
+
/** `og:locale`, `language_TERRITORY`. */
|
|
46
|
+
readonly openGraph: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The app locale's language tags: the app's `locales` entry, else the bare code and `en_US` / `pl_PL`. */
|
|
50
|
+
export function getBlogLocaleTags(config: SoftureConfig): BlogLocaleTags {
|
|
51
|
+
const tags = getBlogOptions(config).locales[config.locale];
|
|
52
|
+
return { bcp47: tags?.bcp47 ?? config.locale, openGraph: tags?.openGraph ?? DEFAULT_OPEN_GRAPH_LOCALES[config.locale] };
|
|
53
|
+
}
|
|
54
|
+
|
|
42
55
|
/** The path of the cache refresh route (`refreshBlogCache`), outside the pages' routes. */
|
|
43
56
|
export function getBlogRefreshPath(config: SoftureConfig): string {
|
|
44
57
|
const path = getBlogModule(config).routes.refresh;
|
package/src/ui/blog-listing.tsx
CHANGED
|
@@ -59,8 +59,8 @@ export function BlogListingView({ context, groups, termCount, timezone, cta }: B
|
|
|
59
59
|
) : (
|
|
60
60
|
<div className="blog-groups">
|
|
61
61
|
{groups.map((group) => {
|
|
62
|
-
const id = group.cluster === null ? undefined : getClusterAnchor(group.cluster);
|
|
63
|
-
const headingId = `${id ?? "
|
|
62
|
+
const id = group.cluster === null ? undefined : getClusterAnchor(group.cluster, context.clusterAnchorPrefix);
|
|
63
|
+
const headingId = `${id ?? getClusterAnchor("other", context.clusterAnchorPrefix)}-heading`;
|
|
64
64
|
return (
|
|
65
65
|
<section key={group.cluster ?? ""} id={id} aria-labelledby={headingId} className="blog-group">
|
|
66
66
|
<h2 id={headingId} className="blog-group-title">
|
package/src/ui/page-context.ts
CHANGED
|
@@ -14,4 +14,6 @@ export interface BlogPageContext {
|
|
|
14
14
|
readonly brand: string | null;
|
|
15
15
|
/** The note under every text in the locale; `null` without one. */
|
|
16
16
|
readonly disclaimer: string | null;
|
|
17
|
+
/** The listing's cluster anchor prefix (`<prefix>-<cluster>`); `cluster` when left out. */
|
|
18
|
+
readonly clusterAnchorPrefix?: string;
|
|
17
19
|
}
|