@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.
Files changed (140) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +98 -17
  3. package/dist/cli/run.js +1 -1
  4. package/dist/cli/run.js.map +1 -1
  5. package/dist/cli/skill.d.ts.map +1 -1
  6. package/dist/cli/skill.js +2 -1
  7. package/dist/cli/skill.js.map +1 -1
  8. package/dist/db/articles.d.ts.map +1 -1
  9. package/dist/db/articles.js +7 -3
  10. package/dist/db/articles.js.map +1 -1
  11. package/dist/db/history.d.ts +7 -4
  12. package/dist/db/history.d.ts.map +1 -1
  13. package/dist/db/history.js +8 -4
  14. package/dist/db/history.js.map +1 -1
  15. package/dist/index.d.ts +41 -5
  16. package/dist/index.d.ts.map +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/messages/en.d.ts +2 -0
  19. package/dist/messages/en.d.ts.map +1 -1
  20. package/dist/messages/en.js +2 -0
  21. package/dist/messages/en.js.map +1 -1
  22. package/dist/messages/index.d.ts +2 -0
  23. package/dist/messages/index.d.ts.map +1 -1
  24. package/dist/messages/pl.d.ts +2 -0
  25. package/dist/messages/pl.d.ts.map +1 -1
  26. package/dist/messages/pl.js +2 -0
  27. package/dist/messages/pl.js.map +1 -1
  28. package/dist/next/context.d.ts.map +1 -1
  29. package/dist/next/context.js +1 -0
  30. package/dist/next/context.js.map +1 -1
  31. package/dist/next/discovery.js +2 -2
  32. package/dist/next/discovery.js.map +1 -1
  33. package/dist/next/index.d.ts +2 -0
  34. package/dist/next/index.d.ts.map +1 -1
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/index.js.map +1 -1
  37. package/dist/next/json-ld.d.ts +13 -0
  38. package/dist/next/json-ld.d.ts.map +1 -0
  39. package/dist/next/json-ld.js +50 -0
  40. package/dist/next/json-ld.js.map +1 -0
  41. package/dist/next/metadata.d.ts +18 -0
  42. package/dist/next/metadata.d.ts.map +1 -0
  43. package/dist/next/metadata.js +86 -0
  44. package/dist/next/metadata.js.map +1 -0
  45. package/dist/next/pages.d.ts.map +1 -1
  46. package/dist/next/pages.js +17 -78
  47. package/dist/next/pages.js.map +1 -1
  48. package/dist/options.d.ts +53 -7
  49. package/dist/options.d.ts.map +1 -1
  50. package/dist/options.js +58 -0
  51. package/dist/options.js.map +1 -1
  52. package/dist/pages/body.d.ts +1 -1
  53. package/dist/pages/body.d.ts.map +1 -1
  54. package/dist/pages/body.js +1 -0
  55. package/dist/pages/body.js.map +1 -1
  56. package/dist/pages/index.d.ts +3 -3
  57. package/dist/pages/index.d.ts.map +1 -1
  58. package/dist/pages/index.js +2 -2
  59. package/dist/pages/index.js.map +1 -1
  60. package/dist/pages/json-ld.d.ts +11 -0
  61. package/dist/pages/json-ld.d.ts.map +1 -1
  62. package/dist/pages/json-ld.js +14 -7
  63. package/dist/pages/json-ld.js.map +1 -1
  64. package/dist/pages/listing.d.ts +9 -3
  65. package/dist/pages/listing.d.ts.map +1 -1
  66. package/dist/pages/listing.js +7 -5
  67. package/dist/pages/listing.js.map +1 -1
  68. package/dist/pages/redirects.d.ts +22 -6
  69. package/dist/pages/redirects.d.ts.map +1 -1
  70. package/dist/pages/redirects.js +5 -2
  71. package/dist/pages/redirects.js.map +1 -1
  72. package/dist/proxy/index.d.ts.map +1 -1
  73. package/dist/proxy/index.js +8 -1
  74. package/dist/proxy/index.js.map +1 -1
  75. package/dist/quality/index.d.ts +1 -1
  76. package/dist/quality/index.d.ts.map +1 -1
  77. package/dist/quality/index.js.map +1 -1
  78. package/dist/quality/link-targets.d.ts.map +1 -1
  79. package/dist/quality/link-targets.js +6 -5
  80. package/dist/quality/link-targets.js.map +1 -1
  81. package/dist/quality/options.d.ts +3 -3
  82. package/dist/quality/options.d.ts.map +1 -1
  83. package/dist/quality/options.js +6 -3
  84. package/dist/quality/options.js.map +1 -1
  85. package/dist/quality/settings.d.ts +11 -0
  86. package/dist/quality/settings.d.ts.map +1 -1
  87. package/dist/quality/settings.js +7 -1
  88. package/dist/quality/settings.js.map +1 -1
  89. package/dist/render/index.d.ts +1 -1
  90. package/dist/render/index.d.ts.map +1 -1
  91. package/dist/render/index.js +1 -1
  92. package/dist/render/index.js.map +1 -1
  93. package/dist/render/render-article.d.ts +8 -0
  94. package/dist/render/render-article.d.ts.map +1 -1
  95. package/dist/render/render-article.js +14 -7
  96. package/dist/render/render-article.js.map +1 -1
  97. package/dist/server/index.d.ts +1 -1
  98. package/dist/server/index.d.ts.map +1 -1
  99. package/dist/server/index.js +1 -1
  100. package/dist/server/index.js.map +1 -1
  101. package/dist/server/options.d.ts +9 -1
  102. package/dist/server/options.d.ts.map +1 -1
  103. package/dist/server/options.js +7 -1
  104. package/dist/server/options.js.map +1 -1
  105. package/dist/ui/blog-listing.js +2 -2
  106. package/dist/ui/blog-listing.js.map +1 -1
  107. package/dist/ui/page-context.d.ts +2 -0
  108. package/dist/ui/page-context.d.ts.map +1 -1
  109. package/module.json +1 -1
  110. package/package.json +1 -1
  111. package/src/cli/run.ts +1 -1
  112. package/src/cli/skill.ts +2 -1
  113. package/src/db/articles.ts +11 -6
  114. package/src/db/history.ts +15 -8
  115. package/src/index.ts +1 -1
  116. package/src/messages/en.ts +2 -0
  117. package/src/messages/pl.ts +2 -0
  118. package/src/next/context.ts +1 -0
  119. package/src/next/discovery.ts +2 -2
  120. package/src/next/index.ts +9 -0
  121. package/src/next/json-ld.ts +55 -0
  122. package/src/next/metadata.ts +105 -0
  123. package/src/next/pages.tsx +17 -83
  124. package/src/options.ts +68 -1
  125. package/src/pages/body.ts +2 -1
  126. package/src/pages/index.ts +6 -1
  127. package/src/pages/json-ld.ts +29 -8
  128. package/src/pages/listing.ts +18 -5
  129. package/src/pages/redirects.ts +27 -3
  130. package/src/proxy/index.ts +9 -1
  131. package/src/quality/index.ts +1 -1
  132. package/src/quality/link-targets.ts +6 -5
  133. package/src/quality/options.ts +6 -3
  134. package/src/quality/settings.ts +17 -1
  135. package/src/render/index.ts +2 -0
  136. package/src/render/render-article.ts +22 -7
  137. package/src/server/index.ts +1 -1
  138. package/src/server/options.ts +15 -2
  139. package/src/ui/blog-listing.tsx +2 -2
  140. package/src/ui/page-context.ts +2 -0
@@ -92,15 +92,38 @@ export interface GonePageCopy {
92
92
  readonly link: string;
93
93
  }
94
94
 
95
+ /** A further way on from the 410 page, after the link to the listing; the label in the app's locale. */
96
+ export interface GonePageLink {
97
+ readonly href: string;
98
+ readonly label: string;
99
+ }
100
+
101
+ /** What `blog({ gonePage: { render } })` receives to write the whole 410 body. */
102
+ export interface GonePageRenderInput {
103
+ readonly copy: GonePageCopy;
104
+ readonly lang: string;
105
+ readonly indexPath: string;
106
+ readonly links: readonly GonePageLink[];
107
+ }
108
+
109
+ export interface GonePageOptions {
110
+ readonly lang: string;
111
+ readonly indexPath: string;
112
+ /** Links listed under the way to the listing (a calculator, a sign-up). */
113
+ readonly links?: readonly GonePageLink[];
114
+ }
115
+
95
116
  function escapeHtml(text: string): string {
96
117
  return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
97
118
  }
98
119
 
99
120
  /**
100
- * The body of a 410: a short page with the way on. No React (the proxy renders no components), no
101
- * script, no external resource; `noindex`. Every value is escaped: copy comes from the app's overrides.
121
+ * The body of a 410: a short page with the way on to the listing and the app's further `links`. No
122
+ * React (the proxy renders no components), no script, no external resource; `noindex`. Every value is
123
+ * escaped: copy and links come from the app's options.
102
124
  */
103
- export function buildGonePage(copy: GonePageCopy, options: { readonly lang: string; readonly indexPath: string }): string {
125
+ export function buildGonePage(copy: GonePageCopy, options: GonePageOptions): string {
126
+ const links = options.links ?? [];
104
127
  return [
105
128
  "<!doctype html>",
106
129
  `<html lang="${escapeHtml(options.lang)}">`,
@@ -113,6 +136,7 @@ export function buildGonePage(copy: GonePageCopy, options: { readonly lang: stri
113
136
  '<body style="font-family:system-ui,sans-serif;max-width:40rem;margin:4rem auto;padding:0 1rem;line-height:1.5">',
114
137
  `<h1>${escapeHtml(copy.heading)}</h1>`,
115
138
  `<p>${escapeHtml(copy.body)} <a href="${escapeHtml(options.indexPath)}">${escapeHtml(copy.link)}</a></p>`,
139
+ ...(links.length === 0 ? [] : ["<ul>", ...links.map((link) => `<li><a href="${escapeHtml(link.href)}">${escapeHtml(link.label)}</a></li>`), "</ul>"]),
116
140
  "</body>",
117
141
  "</html>",
118
142
  "",
@@ -53,7 +53,7 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
53
53
  findRedirect: async (oldSlug) => findSlugRedirect(await getContext(), oldSlug),
54
54
  };
55
55
  const decide = createCachedBlogPathDecider(routes, lookup, options);
56
- const gonePage = buildGonePage(getBlogMessages(config).gone, { lang: config.locale, indexPath: routes.index });
56
+ const gonePage = renderGonePage(config, routes.index);
57
57
 
58
58
  return async (request) => {
59
59
  if (request.method !== "GET" && request.method !== "HEAD") return null;
@@ -74,6 +74,14 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
74
74
  };
75
75
  }
76
76
 
77
+ /** The 410 body: the app's `gonePage.render` when given, else the module's page with the app's links. */
78
+ function renderGonePage(config: SoftureConfig, indexPath: string): string {
79
+ const { gonePage } = getBlogOptions(config);
80
+ const links = gonePage.links.map((link) => ({ href: link.href, label: link.label[config.locale] ?? link.label.en }));
81
+ const input = { copy: getBlogMessages(config).gone, lang: config.locale, indexPath, links };
82
+ return gonePage.render === undefined ? buildGonePage(input.copy, input) : gonePage.render(input);
83
+ }
84
+
77
85
  export type BlogMarkdown = (request: Request) => Promise<Response | null>;
78
86
 
79
87
  export interface BlogMarkdownOptions {
@@ -11,7 +11,7 @@ export { qualityOptionsSchema, type QualityLimits, type QualityOptions, type Qua
11
11
  export { isQualityPlugin, type QualityPlugin, type QualityPluginContext, type QualityRuleInfo } from "./plugin.js";
12
12
  export { collectLinks, type InternalLinkResolver, type LinkSummary } from "./rules/links.js";
13
13
  export { enRuleset, plRuleset, QUALITY_LANGUAGES, QUALITY_RULESETS, type LanguageRuleset, type QualityLanguage, type StylePattern } from "./rulesets/index.js";
14
- export { getLocalDate, resolveQualitySettings, type QualitySettings } from "./settings.js";
14
+ export { getLocalDate, resolveQualitySettings, type QualityPaths, type QualitySettings } from "./settings.js";
15
15
  export {
16
16
  countWords,
17
17
  findBareUrls,
@@ -1,8 +1,9 @@
1
- // Internal link targets from the file system (FIRE_TRACKER `src/lib/blog/quality/link-targets.ts`),
2
- // for `softure-blog check`. A route exists when the Next.js app folder has a `page.*` or `route.*` for
3
- // it (route groups vanish from the path; private segments and `_folders` are skipped). An article or
4
- // a glossary term exists when the content folder has its published file of that kind, under
5
- // `quality.paths`; a static page under the same path wins over the dynamic article route.
1
+ // Internal link targets from the file system, for `softure-blog check`. A route exists when the
2
+ // Next.js app folder has a `page.*` or `route.*` for it (route groups vanish from the path; private
3
+ // segments and `_folders` are skipped). An article or a glossary term exists when the content folder
4
+ // has its published file of that kind, under `QualitySettings.paths` (the blog's routes unless
5
+ // `quality.paths` overrides them); a static page under the same path wins over the dynamic article
6
+ // route.
6
7
  import { existsSync, readdirSync, readFileSync } from "node:fs";
7
8
  import { join } from "node:path";
8
9
  import { parseArticleFile, type ParseArticleFileOptions } from "../content/article-file.js";
@@ -26,7 +26,7 @@ const range = (min: number, max: number) =>
26
26
  const byKind = (article: number, term: number) =>
27
27
  z.strictObject({ article: z.number().int().min(0).default(article), term: z.number().int().min(0).default(term) }).prefault({});
28
28
 
29
- /** Thresholds; the defaults are FIRE's, chosen for answer-first texts that AI assistants quote. */
29
+ /** Thresholds; the defaults suit answer-first texts that AI assistants quote. */
30
30
  export const qualityLimitsSchema = z
31
31
  .strictObject({
32
32
  words: z.strictObject({ article: range(600, 4000), term: range(60, 700) }).prefault({}),
@@ -75,8 +75,11 @@ export const qualityOptionsSchema = z.strictObject({
75
75
  limits: qualityLimitsSchema,
76
76
  /** Per rule: another severity, or "off". */
77
77
  severity: z.record(z.string().regex(KEBAB), z.enum(["error", "warning", "off"])).default({}),
78
- /** Where the pages live; BL-4 serves them there. */
79
- paths: z.strictObject({ articles: sitePath.default("/blog"), terms: sitePath.default("/blog/glossary") }).prefault({}),
78
+ /**
79
+ * Where articles and terms live, only to override the blog's `routes` (`articles` defaults to
80
+ * `routes.index`, `terms` to `routes.glossary`). The resolved pair is `QualitySettings.paths`.
81
+ */
82
+ paths: z.strictObject({ articles: sitePath.optional(), terms: sitePath.optional() }).prefault({}),
80
83
  /** Absolute origins whose links count as internal, besides the config's `appOrigin` and the canonical site origin (`getSiteUrls`). */
81
84
  ownOrigins: z.array(z.url({ protocol: /^https?$/ })).default([]),
82
85
  /** The Next.js app folder `softure-blog check` reads routes from. Default: `src/app`, else `app`. */
@@ -18,13 +18,25 @@ export interface QualitySettings {
18
18
  readonly timeZone: string;
19
19
  /** The app's image policy (`blog({ images })`); `null` when bodies may show no image. */
20
20
  readonly images: ArticleImagePolicy | null;
21
+ /** Where articles and terms live: `options.paths` where set, else the blog's routes. */
22
+ readonly paths: QualityPaths;
21
23
  }
22
24
 
25
+ export interface QualityPaths {
26
+ readonly articles: string;
27
+ readonly terms: string;
28
+ }
29
+
30
+ /** The paths a caller without the blog's routes gets: the module's default routes. */
31
+ const DEFAULT_PATHS: QualityPaths = { articles: "/blog", terms: "/blog/glossary" };
32
+
23
33
  export function resolveQualitySettings(
24
34
  options: QualityOptions,
25
35
  config: Pick<SoftureConfig, "appOrigin" | "timezone"> & {
26
36
  /** The canonical site origin (core's `getSiteUrls(config).origin`) when it is not `appOrigin`. */
27
37
  readonly siteOrigin?: string;
38
+ /** The blog's routes (`getBlogRoutes`), the source of `paths` unless `options.paths` overrides them. */
39
+ readonly routes?: { readonly index: string; readonly glossary: string };
28
40
  },
29
41
  images: ArticleImagePolicy | null = null,
30
42
  ): QualitySettings {
@@ -44,7 +56,11 @@ export function resolveQualitySettings(
44
56
  });
45
57
  }
46
58
  const origins = [config.appOrigin, ...(config.siteOrigin === undefined ? [] : [config.siteOrigin]), ...options.ownOrigins].map((origin) => new URL(origin).origin);
47
- return { options, ruleset, voicePatterns, ownOrigins: [...new Set(origins)], timeZone: config.timezone, images };
59
+ const paths: QualityPaths = {
60
+ articles: options.paths.articles ?? config.routes?.index ?? DEFAULT_PATHS.articles,
61
+ terms: options.paths.terms ?? config.routes?.glossary ?? DEFAULT_PATHS.terms,
62
+ };
63
+ return { options, ruleset, voicePatterns, ownOrigins: [...new Set(origins)], timeZone: config.timezone, images, paths };
48
64
  }
49
65
 
50
66
  /** `YYYY-MM-DD` of a moment in a time zone. */
@@ -17,6 +17,7 @@ export {
17
17
  findArticleBlocks,
18
18
  parseDirectiveAttributes,
19
19
  parseDirectiveLine,
20
+ EXTERNAL_LINK_MARKERS,
20
21
  renderArticle,
21
22
  replaceArticleBlocks,
22
23
  type ArticleBlock,
@@ -28,6 +29,7 @@ export {
28
29
  type BlockOutput,
29
30
  type BlockPlugin,
30
31
  type BlogRenderMessages,
32
+ type ExternalLinkMarker,
31
33
  type FoundBlock,
32
34
  type RenderArticleOptions,
33
35
  type RenderedArticle,
@@ -17,7 +17,8 @@
17
17
  // ## Links
18
18
  //
19
19
  // A link that leaves the site (`siteHosts`, subdomains included) gets `rel="noopener noreferrer"`,
20
- // opens in a new tab and carries a visible marker plus a visually hidden "opens in a new tab".
20
+ // opens in a new tab and carries a visible marker plus a visually hidden "opens in a new tab"
21
+ // (`externalMarker`: both, the hidden words only, or neither; the `blog-external` class stays either way).
21
22
  //
22
23
  // ## Headings and the table of contents
23
24
  //
@@ -114,6 +115,11 @@ export type ArticleSegment<TNode = unknown> =
114
115
  | { readonly kind: "html"; readonly html: string }
115
116
  | { readonly kind: "node"; readonly type: string; readonly node: TNode };
116
117
 
118
+ /** What the renderer appends to an external link (`RenderArticleOptions.externalMarker`). */
119
+ export const EXTERNAL_LINK_MARKERS = ["icon-and-text", "text", "none"] as const;
120
+
121
+ export type ExternalLinkMarker = (typeof EXTERNAL_LINK_MARKERS)[number];
122
+
117
123
  export interface RenderArticleOptions<TNode = unknown> {
118
124
  /** Terms for automatic links; without it no text is linked. */
119
125
  readonly glossary?: readonly GlossaryTerm[];
@@ -130,6 +136,11 @@ export interface RenderArticleOptions<TNode = unknown> {
130
136
  readonly article?: BlockArticle;
131
137
  /** Render a table of contents of `h2` down to `maxLevel` (3 by default). */
132
138
  readonly toc?: boolean | { readonly maxLevel: number };
139
+ /**
140
+ * What follows an external link: the visible arrow and the visually hidden "opens in a new tab"
141
+ * (`"icon-and-text"`, the default), the hidden words only (`"text"`), or nothing (`"none"`).
142
+ */
143
+ readonly externalMarker?: ExternalLinkMarker;
133
144
  /** Copy for footnotes, external links and the table of contents; English by default. */
134
145
  readonly messages?: BlogRenderMessages;
135
146
  readonly wordsPerMinute?: number;
@@ -297,7 +308,14 @@ function addFootnoteMarkup(md: Markdown, messages: BlogRenderMessages): void {
297
308
  };
298
309
  }
299
310
 
300
- function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages: BlogRenderMessages): void {
311
+ function getExternalMarkerHtml(md: Markdown, marker: ExternalLinkMarker, messages: BlogRenderMessages): string {
312
+ if (marker === "none") return "";
313
+ const hidden = `<span class="blog-visually-hidden"> ${md.utils.escapeHtml(messages.opensInNewTab)}</span>`;
314
+ return marker === "text" ? hidden : `<span class="blog-external-marker" aria-hidden="true">↗</span>${hidden}`;
315
+ }
316
+
317
+ function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages: BlogRenderMessages, marker: ExternalLinkMarker): void {
318
+ const markerHtml = getExternalMarkerHtml(md, marker, messages);
301
319
  // Markdown links do not nest, but a stack keeps open and close paired whatever the token stream.
302
320
  const externalStack: boolean[] = [];
303
321
  md.renderer.rules.link_open = (tokens, index, options, _env, self) => {
@@ -312,10 +330,7 @@ function addExternalLinks(md: Markdown, siteHosts: readonly string[], messages:
312
330
  return self.renderToken(tokens, index, options);
313
331
  };
314
332
  md.renderer.rules.link_close = (tokens, index, options, _env, self) => {
315
- const marker = externalStack.pop() === true
316
- ? `<span class="blog-external-marker" aria-hidden="true">↗</span><span class="blog-visually-hidden"> ${md.utils.escapeHtml(messages.opensInNewTab)}</span>`
317
- : "";
318
- return marker + self.renderToken(tokens, index, options);
333
+ return (externalStack.pop() === true ? markerHtml : "") + self.renderToken(tokens, index, options);
319
334
  };
320
335
  }
321
336
 
@@ -513,7 +528,7 @@ function createMarkdown<TNode>(
513
528
  md.validateLink = isSafeLink;
514
529
  addImages(md, options.images);
515
530
  addFootnoteMarkup(md, messages);
516
- addExternalLinks(md, options.siteHosts ?? [], messages);
531
+ addExternalLinks(md, options.siteHosts ?? [], messages, options.externalMarker ?? "icon-and-text");
517
532
  addBlockTokens(md, getTypes(plugins, "fence"));
518
533
  addDirectiveRule(md, getTypes(plugins, "directive"));
519
534
  addHeadingIds(md, state);
@@ -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;
@@ -57,6 +70,6 @@ export function getBlogReservedSlugs(config: SoftureConfig): string[] {
57
70
  export function getQualitySettings(config: SoftureConfig): QualitySettings | null {
58
71
  const { quality, images } = getBlogOptions(config);
59
72
  if (quality === false) return null;
60
- const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone };
73
+ const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone, routes: getBlogRoutes(config) };
61
74
  return resolveQualitySettings(quality, site, images ?? null);
62
75
  }
@@ -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
  }