@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +21 -0
  3. package/dist/index.d.ts +21 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/messages/en.d.ts +2 -0
  7. package/dist/messages/en.d.ts.map +1 -1
  8. package/dist/messages/en.js +2 -0
  9. package/dist/messages/en.js.map +1 -1
  10. package/dist/messages/index.d.ts +2 -0
  11. package/dist/messages/index.d.ts.map +1 -1
  12. package/dist/messages/pl.d.ts +2 -0
  13. package/dist/messages/pl.d.ts.map +1 -1
  14. package/dist/messages/pl.js +2 -0
  15. package/dist/messages/pl.js.map +1 -1
  16. package/dist/next/context.d.ts.map +1 -1
  17. package/dist/next/context.js +1 -0
  18. package/dist/next/context.js.map +1 -1
  19. package/dist/next/discovery.js +2 -2
  20. package/dist/next/discovery.js.map +1 -1
  21. package/dist/next/json-ld.d.ts.map +1 -1
  22. package/dist/next/json-ld.js +11 -3
  23. package/dist/next/json-ld.js.map +1 -1
  24. package/dist/next/metadata.d.ts +1 -1
  25. package/dist/next/metadata.d.ts.map +1 -1
  26. package/dist/next/metadata.js +8 -6
  27. package/dist/next/metadata.js.map +1 -1
  28. package/dist/next/pages.js +1 -1
  29. package/dist/next/pages.js.map +1 -1
  30. package/dist/options.d.ts +30 -0
  31. package/dist/options.d.ts.map +1 -1
  32. package/dist/options.js +35 -0
  33. package/dist/options.js.map +1 -1
  34. package/dist/pages/index.d.ts +2 -2
  35. package/dist/pages/index.d.ts.map +1 -1
  36. package/dist/pages/index.js +2 -2
  37. package/dist/pages/index.js.map +1 -1
  38. package/dist/pages/json-ld.d.ts +11 -0
  39. package/dist/pages/json-ld.d.ts.map +1 -1
  40. package/dist/pages/json-ld.js +14 -7
  41. package/dist/pages/json-ld.js.map +1 -1
  42. package/dist/pages/listing.d.ts +9 -3
  43. package/dist/pages/listing.d.ts.map +1 -1
  44. package/dist/pages/listing.js +7 -5
  45. package/dist/pages/listing.js.map +1 -1
  46. package/dist/server/index.d.ts +1 -1
  47. package/dist/server/index.d.ts.map +1 -1
  48. package/dist/server/index.js +1 -1
  49. package/dist/server/index.js.map +1 -1
  50. package/dist/server/options.d.ts +9 -1
  51. package/dist/server/options.d.ts.map +1 -1
  52. package/dist/server/options.js +6 -0
  53. package/dist/server/options.js.map +1 -1
  54. package/dist/ui/blog-listing.js +2 -2
  55. package/dist/ui/blog-listing.js.map +1 -1
  56. package/dist/ui/page-context.d.ts +2 -0
  57. package/dist/ui/page-context.d.ts.map +1 -1
  58. package/module.json +1 -1
  59. package/package.json +1 -1
  60. package/src/index.ts +1 -1
  61. package/src/messages/en.ts +2 -0
  62. package/src/messages/pl.ts +2 -0
  63. package/src/next/context.ts +1 -0
  64. package/src/next/discovery.ts +2 -2
  65. package/src/next/json-ld.ts +11 -3
  66. package/src/next/metadata.ts +9 -7
  67. package/src/next/pages.tsx +1 -1
  68. package/src/options.ts +42 -0
  69. package/src/pages/index.ts +3 -1
  70. package/src/pages/json-ld.ts +29 -8
  71. package/src/pages/listing.ts +18 -5
  72. package/src/server/index.ts +1 -1
  73. package/src/server/options.ts +14 -1
  74. package/src/ui/blog-listing.tsx +2 -2
  75. 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`. */
@@ -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,
@@ -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}#article`,
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.locale,
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)}#glossary`, name: glossaryTitle };
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}#term`,
116
+ "@id": `${url}#${getIds(ctx).term}`,
96
117
  url,
97
118
  name: term.title,
98
119
  description: term.description,
99
- inLanguage: ctx.locale,
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.locale,
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}#term`, name: term.title, description: term.description, url };
138
+ return { "@type": "DefinedTerm", "@id": `${url}#${getIds(ctx).term}`, name: term.title, description: term.description, url };
118
139
  }),
119
140
  };
120
141
  }
@@ -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 of a cluster's section on the listing: the target of the article's middle crumb. */
27
- export function getClusterAnchor(cluster: string): string {
28
- return `cluster-${cluster}`;
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(article: Pick<BlogArticle, "slug" | "title" | "cluster">, routes: BlogRoutes, labels: CrumbLabels): Crumb[] {
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;
@@ -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";
@@ -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 { BlogOptions } from "../options.js";
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;
@@ -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 ?? "cluster-other"}-heading`;
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">
@@ -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
  }