@softure-ai/blog 0.1.6 → 0.1.8

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 (143) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +211 -26
  3. package/dist/cli/report.d.ts +17 -0
  4. package/dist/cli/report.d.ts.map +1 -0
  5. package/dist/cli/report.js +149 -0
  6. package/dist/cli/report.js.map +1 -0
  7. package/dist/cli/run.d.ts +12 -5
  8. package/dist/cli/run.d.ts.map +1 -1
  9. package/dist/cli/run.js +122 -88
  10. package/dist/cli/run.js.map +1 -1
  11. package/dist/cli/skill.d.ts.map +1 -1
  12. package/dist/cli/skill.js +2 -1
  13. package/dist/cli/skill.js.map +1 -1
  14. package/dist/contract.d.ts +4 -0
  15. package/dist/contract.d.ts.map +1 -1
  16. package/dist/db/articles.d.ts +10 -1
  17. package/dist/db/articles.d.ts.map +1 -1
  18. package/dist/db/articles.js +41 -5
  19. package/dist/db/articles.js.map +1 -1
  20. package/dist/db/history.d.ts +36 -0
  21. package/dist/db/history.d.ts.map +1 -0
  22. package/dist/db/history.js +70 -0
  23. package/dist/db/history.js.map +1 -0
  24. package/dist/db/publish-run.d.ts +11 -0
  25. package/dist/db/publish-run.d.ts.map +1 -1
  26. package/dist/db/publish-run.js +14 -4
  27. package/dist/db/publish-run.js.map +1 -1
  28. package/dist/index.d.ts +20 -5
  29. package/dist/index.d.ts.map +1 -1
  30. package/dist/index.js +1 -1
  31. package/dist/next/index.d.ts +2 -0
  32. package/dist/next/index.d.ts.map +1 -1
  33. package/dist/next/index.js +2 -0
  34. package/dist/next/index.js.map +1 -1
  35. package/dist/next/json-ld.d.ts +13 -0
  36. package/dist/next/json-ld.d.ts.map +1 -0
  37. package/dist/next/json-ld.js +42 -0
  38. package/dist/next/json-ld.js.map +1 -0
  39. package/dist/next/metadata.d.ts +18 -0
  40. package/dist/next/metadata.d.ts.map +1 -0
  41. package/dist/next/metadata.js +84 -0
  42. package/dist/next/metadata.js.map +1 -0
  43. package/dist/next/pages.d.ts.map +1 -1
  44. package/dist/next/pages.js +16 -77
  45. package/dist/next/pages.js.map +1 -1
  46. package/dist/options.d.ts +23 -7
  47. package/dist/options.d.ts.map +1 -1
  48. package/dist/options.js +26 -1
  49. package/dist/options.js.map +1 -1
  50. package/dist/pages/accept.d.ts +7 -0
  51. package/dist/pages/accept.d.ts.map +1 -0
  52. package/dist/pages/accept.js +34 -0
  53. package/dist/pages/accept.js.map +1 -0
  54. package/dist/pages/body.d.ts +1 -1
  55. package/dist/pages/body.d.ts.map +1 -1
  56. package/dist/pages/body.js +1 -0
  57. package/dist/pages/body.js.map +1 -1
  58. package/dist/pages/index.d.ts +1 -1
  59. package/dist/pages/index.d.ts.map +1 -1
  60. package/dist/pages/index.js.map +1 -1
  61. package/dist/pages/redirects.d.ts +22 -6
  62. package/dist/pages/redirects.d.ts.map +1 -1
  63. package/dist/pages/redirects.js +5 -2
  64. package/dist/pages/redirects.js.map +1 -1
  65. package/dist/proxy/index.d.ts +15 -0
  66. package/dist/proxy/index.d.ts.map +1 -1
  67. package/dist/proxy/index.js +60 -3
  68. package/dist/proxy/index.js.map +1 -1
  69. package/dist/quality/catalog.d.ts.map +1 -1
  70. package/dist/quality/catalog.js +4 -1
  71. package/dist/quality/catalog.js.map +1 -1
  72. package/dist/quality/check-article.d.ts.map +1 -1
  73. package/dist/quality/check-article.js +2 -1
  74. package/dist/quality/check-article.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 +9 -4
  84. package/dist/quality/options.js.map +1 -1
  85. package/dist/quality/rules/blocks.d.ts +8 -1
  86. package/dist/quality/rules/blocks.d.ts.map +1 -1
  87. package/dist/quality/rules/blocks.js +26 -0
  88. package/dist/quality/rules/blocks.js.map +1 -1
  89. package/dist/quality/settings.d.ts +11 -0
  90. package/dist/quality/settings.d.ts.map +1 -1
  91. package/dist/quality/settings.js +7 -1
  92. package/dist/quality/settings.js.map +1 -1
  93. package/dist/render/article-markdown.d.ts +12 -0
  94. package/dist/render/article-markdown.d.ts.map +1 -0
  95. package/dist/render/article-markdown.js +21 -0
  96. package/dist/render/article-markdown.js.map +1 -0
  97. package/dist/render/index.d.ts +2 -1
  98. package/dist/render/index.d.ts.map +1 -1
  99. package/dist/render/index.js +2 -1
  100. package/dist/render/index.js.map +1 -1
  101. package/dist/render/render-article.d.ts +48 -4
  102. package/dist/render/render-article.d.ts.map +1 -1
  103. package/dist/render/render-article.js +146 -17
  104. package/dist/render/render-article.js.map +1 -1
  105. package/dist/server/index.d.ts +2 -1
  106. package/dist/server/index.d.ts.map +1 -1
  107. package/dist/server/index.js +1 -0
  108. package/dist/server/index.js.map +1 -1
  109. package/dist/server/options.js +1 -1
  110. package/dist/server/options.js.map +1 -1
  111. package/module.json +1 -1
  112. package/package.json +1 -1
  113. package/skill/references/rules.md +2 -1
  114. package/src/cli/report.ts +182 -0
  115. package/src/cli/run.ts +122 -92
  116. package/src/cli/skill.ts +2 -1
  117. package/src/contract.ts +9 -1
  118. package/src/db/articles.ts +48 -8
  119. package/src/db/history.ts +90 -0
  120. package/src/db/publish-run.ts +24 -4
  121. package/src/index.ts +1 -1
  122. package/src/next/index.ts +9 -0
  123. package/src/next/json-ld.ts +47 -0
  124. package/src/next/metadata.ts +103 -0
  125. package/src/next/pages.tsx +16 -82
  126. package/src/options.ts +32 -3
  127. package/src/pages/accept.ts +39 -0
  128. package/src/pages/body.ts +2 -1
  129. package/src/pages/index.ts +3 -0
  130. package/src/pages/redirects.ts +27 -3
  131. package/src/proxy/index.ts +69 -3
  132. package/src/quality/catalog.ts +4 -1
  133. package/src/quality/check-article.ts +2 -1
  134. package/src/quality/index.ts +1 -1
  135. package/src/quality/link-targets.ts +6 -5
  136. package/src/quality/options.ts +12 -5
  137. package/src/quality/rules/blocks.ts +26 -1
  138. package/src/quality/settings.ts +17 -1
  139. package/src/render/article-markdown.ts +34 -0
  140. package/src/render/index.ts +8 -0
  141. package/src/render/render-article.ts +192 -23
  142. package/src/server/index.ts +2 -0
  143. package/src/server/options.ts +1 -1
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 { BlockPlugin } from "./render/render-article.js";
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";
@@ -50,8 +51,12 @@ function isFieldsSchema(value: unknown): value is BlogFieldsSchema {
50
51
 
51
52
  function isBlockPlugin(value: unknown): value is BlockPlugin {
52
53
  if (typeof value !== "object" || value === null) return false;
53
- const candidate = value as { type?: unknown; render?: unknown };
54
- return typeof candidate.type === "string" && KEBAB.test(candidate.type) && typeof candidate.render === "function";
54
+ const candidate = value as { type?: unknown; syntax?: unknown; render?: unknown; markdown?: unknown };
55
+ return (
56
+ typeof candidate.type === "string" && KEBAB.test(candidate.type) && typeof candidate.render === "function" &&
57
+ (candidate.syntax === undefined || candidate.syntax === "fence" || candidate.syntax === "directive") &&
58
+ (candidate.markdown === undefined || typeof candidate.markdown === "function")
59
+ );
55
60
  }
56
61
 
57
62
  /** How long the pages cache their reads by default, in seconds; the app's `revalidate` should match. */
@@ -158,6 +163,23 @@ const skillSectionSchema = z.strictObject({
158
163
  .refine((body) => !hasTopHeading(body), "must not hold a # or ## heading; use ### and deeper (the title is the section's ## heading)"),
159
164
  });
160
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
+
161
183
  const skillSchema = z.strictObject({
162
184
  /** The app's own sections (its numbers, block plugins, fields), written to `references/app.md` of the skill. */
163
185
  sections: z.array(skillSectionSchema).superRefine((sections, ctx) => {
@@ -200,11 +222,18 @@ export const blogOptionsSchema = z
200
222
  blocks: z.array(z.custom<BlockPlugin>(isBlockPlugin, "must be a block plugin: { type: \"chart\", render(block) }")).default([]),
201
223
  /** Hosts besides those of `appOrigin` and the canonical site origin (`getSiteUrls`) whose links are not marked external (subdomains included). */
202
224
  siteHosts: z.array(z.string().regex(HOSTNAME, "must be a host name, e.g. example.com")).default([]),
225
+ /**
226
+ * What the pages append to an external link (`renderArticle({ externalMarker })`): the arrow and a
227
+ * visually hidden "opens in a new tab", the hidden words only, or nothing.
228
+ */
229
+ externalLinkMarker: z.enum(EXTERNAL_LINK_MARKERS).default("icon-and-text"),
203
230
  /**
204
231
  * Which images article bodies may show (`renderArticle({ images })`), used by the pages and the
205
232
  * quality gate; without it every image renders as its alt text and the gate refuses it.
206
233
  */
207
234
  images: imagePolicySchema.optional(),
235
+ /** The page a withdrawn text answers with (410): extra links, or the app's own body. */
236
+ gonePage: gonePageSchema.default({ links: [] }),
208
237
  /** How long the listing and glossary cache their reads; keep it equal to the pages' `revalidate`. */
209
238
  revalidateSeconds: z.number().int().min(1).default(DEFAULT_REVALIDATE_SECONDS),
210
239
  /** The text quality gate (`softure-blog check`, and every publish); `false` turns it off. */
@@ -0,0 +1,39 @@
1
+ // Content negotiation for the Markdown representation: which requests ask for Markdown.
2
+
3
+ interface MediaRange {
4
+ readonly type: string;
5
+ readonly subtype: string;
6
+ readonly q: number;
7
+ }
8
+
9
+ function parseAccept(accept: string): MediaRange[] {
10
+ return accept.split(",").flatMap((part) => {
11
+ const [range = "", ...params] = part.trim().toLowerCase().split(";");
12
+ const [type, subtype] = range.trim().split("/");
13
+ if (type === undefined || type === "" || subtype === undefined || subtype === "") return [];
14
+ const qParam = params.map((param) => param.trim()).find((param) => param.startsWith("q="));
15
+ const q = qParam === undefined ? 1 : Number(qParam.slice(2));
16
+ return Number.isFinite(q) ? [{ type, subtype, q }] : [];
17
+ });
18
+ }
19
+
20
+ /** The weight of a type by the most specific matching range (RFC 9110 §12.5.1); 0 without a match. */
21
+ function getQuality(ranges: readonly MediaRange[], type: string, subtype: string): number {
22
+ const exact = ranges.find((range) => range.type === type && range.subtype === subtype);
23
+ const group = ranges.find((range) => range.type === type && range.subtype === "*");
24
+ const any = ranges.find((range) => range.type === "*" && range.subtype === "*");
25
+ return (exact ?? group ?? any)?.q ?? 0;
26
+ }
27
+
28
+ /**
29
+ * Whether an `Accept` header asks for Markdown rather than HTML. `text/markdown` must be named: `*\/*`
30
+ * (curl, most crawlers, browser prefetches) matches Markdown as well as HTML, and they want the page.
31
+ * A tie goes to Markdown: a client that names it next to HTML with the same weight is an agent.
32
+ */
33
+ export function prefersMarkdown(accept: string | null): boolean {
34
+ if (accept === null) return false;
35
+ const ranges = parseAccept(accept);
36
+ const markdown = ranges.find((range) => range.type === "text" && range.subtype === "markdown");
37
+ if (markdown === undefined || markdown.q <= 0) return false;
38
+ return markdown.q >= getQuality(ranges, "text", "html");
39
+ }
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
  }
@@ -34,4 +34,7 @@ export {
34
34
  type BlogPathLookup,
35
35
  type CachedDeciderOptions,
36
36
  type GonePageCopy,
37
+ type GonePageLink,
38
+ type GonePageOptions,
39
+ type GonePageRenderInput,
37
40
  } from "./redirects.js";
@@ -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
  "",
@@ -8,12 +8,21 @@
8
8
  // }
9
9
  //
10
10
  // Next 16 runs `proxy.ts` on Node.js, so the database handle is the process-wide one.
11
+ //
12
+ // `createBlogMarkdown` answers an article or term page asked for with `Accept: text/markdown` with the
13
+ // text as Markdown; put it before the redirects so a moved or withdrawn text still answers as before:
14
+ //
15
+ // return (await blogMarkdown(request)) ?? (await blogRedirects(request)) ?? …
11
16
  import { systemClock, type SoftureConfig } from "@softure-ai/core";
12
17
  import { getConfiguredDatabase } from "@softure-ai/db";
13
- import { findArticleBySlug, findSlugRedirect, type BlogContext } from "../db/articles.js";
18
+ import { findArticleBySlug, findSlugRedirect, getPublishedArticle, type BlogContext } from "../db/articles.js";
19
+ import { prefersMarkdown } from "../pages/accept.js";
20
+ import { toArticleMarkdown } from "../render/article-markdown.js";
14
21
  import { matchBlogPath } from "../pages/paths.js";
15
22
  import { buildGonePage, createCachedBlogPathDecider, type BlogPathLookup, type CachedDeciderOptions } from "../pages/redirects.js";
16
- import { getBlogMessages, getBlogReservedSlugs, getBlogRoutes } from "../server/options.js";
23
+ import { getBlogMessages, getBlogOptions, getBlogReservedSlugs, getBlogRoutes } from "../server/options.js";
24
+
25
+ export { prefersMarkdown };
17
26
 
18
27
  export type BlogRedirects = (request: Request) => Promise<Response | null>;
19
28
 
@@ -44,7 +53,7 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
44
53
  findRedirect: async (oldSlug) => findSlugRedirect(await getContext(), oldSlug),
45
54
  };
46
55
  const decide = createCachedBlogPathDecider(routes, lookup, options);
47
- const gonePage = buildGonePage(getBlogMessages(config).gone, { lang: config.locale, indexPath: routes.index });
56
+ const gonePage = renderGonePage(config, routes.index);
48
57
 
49
58
  return async (request) => {
50
59
  if (request.method !== "GET" && request.method !== "HEAD") return null;
@@ -64,3 +73,60 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
64
73
  return null;
65
74
  };
66
75
  }
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
+
85
+ export type BlogMarkdown = (request: Request) => Promise<Response | null>;
86
+
87
+ export interface BlogMarkdownOptions {
88
+ /** The store's context; the shared database handle of `config.database` by default (tests pass PGlite). */
89
+ readonly getContext?: () => Promise<BlogContext>;
90
+ /** Where a failed read is reported; `console.error` by default. The request then goes on to the page. */
91
+ readonly onError?: (message: string) => void;
92
+ }
93
+
94
+ const MARKDOWN_HEADERS = {
95
+ "content-type": "text/markdown; charset=utf-8",
96
+ // One address, two representations. `private`: a shared cache must not hand Markdown to a browser.
97
+ vary: "Accept",
98
+ "cache-control": "private, max-age=0, must-revalidate",
99
+ };
100
+
101
+ /**
102
+ * Answers a GET or HEAD of a published article or term whose `Accept` asks for Markdown
103
+ * (`prefersMarkdown`) with the text as Markdown (`toArticleMarkdown`, the app's block plugins giving
104
+ * their Markdown form). `null` for everything else, and when the read fails: the page answers then.
105
+ */
106
+ export function createBlogMarkdown(config: SoftureConfig, options: BlogMarkdownOptions = {}): BlogMarkdown {
107
+ const routes = getBlogRoutes(config);
108
+ const reservedSlugs = getBlogReservedSlugs(config);
109
+ const getContext = options.getContext ?? createDefaultContext(config);
110
+ const onError = options.onError ?? ((message: string) => console.error(message));
111
+ const { blocks } = getBlogOptions(config);
112
+ const messages = getBlogMessages(config).pages;
113
+
114
+ return async (request) => {
115
+ if (request.method !== "GET" && request.method !== "HEAD") return null;
116
+ if (!prefersMarkdown(request.headers.get("accept"))) return null;
117
+ const url = new URL(request.url);
118
+ const match = matchBlogPath(url.pathname, routes, reservedSlugs);
119
+ if (match === null) return null;
120
+ let article;
121
+ try {
122
+ article = await getPublishedArticle(await getContext(), match.slug);
123
+ } catch (error) {
124
+ onError(`@softure-ai/blog: reading ${url.pathname} as Markdown failed: ${error instanceof Error ? error.message : String(error)}`);
125
+ return null;
126
+ }
127
+ if (article?.kind !== match.kind) return null;
128
+ const body = toArticleMarkdown(article, { blocks, messages });
129
+ const headers = { ...MARKDOWN_HEADERS, "x-markdown-tokens": String(Math.ceil(body.length / 4)) };
130
+ return new Response(request.method === "HEAD" ? null : body, { status: 200, headers });
131
+ };
132
+ }
@@ -75,7 +75,10 @@ export function listQualityRules(settings: QualitySettings): QualityCatalogRule[
75
75
  ...ruleset.patterns.map((pattern) => rule("style", pattern.id, pattern.severity, pattern.message)),
76
76
  ...settings.voicePatterns.map((pattern) => rule("voice", pattern.id, pattern.severity, pattern.message)),
77
77
  ...(options.ymyl === null ? [] : YMYL_RULES),
78
- ...(options.blocks.length === 0 ? [] : [rule("structure", "block-requires", "error", "a block plugin's fenced block has the frontmatter keys it requires")]),
78
+ ...(options.blocks.length === 0 ? [] : [rule("structure", "block-requires", "error", "a block plugin's block has the frontmatter keys it requires")]),
79
+ ...(options.blocks.some((plugin) => plugin.syntax === "directive")
80
+ ? [rule("structure", "block-directive", "error", "a ::directive line names a directive the blog renders, with readable key=\"value\" attributes")]
81
+ : []),
79
82
  ...(options.plugins.length === 0 ? [] : PLUGIN_RULES),
80
83
  ...options.plugins.flatMap((plugin) => plugin.rules.map((info) => rule("plugin", info.id, info.severity, info.description))),
81
84
  ];
@@ -8,7 +8,7 @@ import { parseArticleFile, type ParseArticleFileOptions } from "../content/artic
8
8
  import { splitArticleBody, splitBlocks } from "./blocks.js";
9
9
  import { sortFindings, type QualityFinding } from "./finding.js";
10
10
  import type { QualityPlugin } from "./plugin.js";
11
- import { checkBlockRequires } from "./rules/blocks.js";
11
+ import { checkBlockRequires, checkDirectives } from "./rules/blocks.js";
12
12
  import { checkImages } from "./rules/images.js";
13
13
  import type { RuleInput } from "./rules/input.js";
14
14
  import { checkLinks, collectLinks, type InternalLinkResolver } from "./rules/links.js";
@@ -58,6 +58,7 @@ export function checkArticle(input: CheckArticleInput): QualityCheckResult {
58
58
  ...checkStyle(ruleInput),
59
59
  ...checkRhythm(ruleInput),
60
60
  ...checkBlockRequires(article, pluginBlocks),
61
+ ...checkDirectives(blocks, pluginBlocks, settings.options.blocks),
61
62
  ...checkImages(images, settings.images),
62
63
  ];
63
64
  const fromPlugins = settings.options.plugins.flatMap((plugin) => runPlugin(plugin, { article, blocks, pluginBlocks, today, ruleset: settings.ruleset }));
@@ -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";
@@ -9,8 +9,12 @@ const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
9
9
 
10
10
  function isBlockPlugin(value: unknown): value is BlockPlugin {
11
11
  if (typeof value !== "object" || value === null) return false;
12
- const candidate = value as { type?: unknown; render?: unknown };
13
- return typeof candidate.type === "string" && typeof candidate.render === "function";
12
+ const candidate = value as { type?: unknown; syntax?: unknown; render?: unknown; markdown?: unknown };
13
+ return (
14
+ typeof candidate.type === "string" && typeof candidate.render === "function" &&
15
+ (candidate.syntax === undefined || candidate.syntax === "fence" || candidate.syntax === "directive") &&
16
+ (candidate.markdown === undefined || typeof candidate.markdown === "function")
17
+ );
14
18
  }
15
19
 
16
20
  const range = (min: number, max: number) =>
@@ -22,7 +26,7 @@ const range = (min: number, max: number) =>
22
26
  const byKind = (article: number, term: number) =>
23
27
  z.strictObject({ article: z.number().int().min(0).default(article), term: z.number().int().min(0).default(term) }).prefault({});
24
28
 
25
- /** 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. */
26
30
  export const qualityLimitsSchema = z
27
31
  .strictObject({
28
32
  words: z.strictObject({ article: range(600, 4000), term: range(60, 700) }).prefault({}),
@@ -71,8 +75,11 @@ export const qualityOptionsSchema = z.strictObject({
71
75
  limits: qualityLimitsSchema,
72
76
  /** Per rule: another severity, or "off". */
73
77
  severity: z.record(z.string().regex(KEBAB), z.enum(["error", "warning", "off"])).default({}),
74
- /** Where the pages live; BL-4 serves them there. */
75
- 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({}),
76
83
  /** Absolute origins whose links count as internal, besides the config's `appOrigin` and the canonical site origin (`getSiteUrls`). */
77
84
  ownOrigins: z.array(z.url({ protocol: /^https?$/ })).default([]),
78
85
  /** The Next.js app folder `softure-blog check` reads routes from. Default: `src/app`, else `app`. */
@@ -1,7 +1,8 @@
1
1
  // Block plugin needs (BL-3): a fenced block of a block plugin reads frontmatter keys (`requires`), and
2
2
  // an article that lacks one would render the block without its data.
3
3
  import type { BlogArticleInput } from "../../contract.js";
4
- import type { FoundBlock } from "../../render/render-article.js";
4
+ import { parseDirectiveLine, type BlockPlugin, type FoundBlock } from "../../render/render-article.js";
5
+ import type { Block } from "../blocks.js";
5
6
  import type { QualityFinding } from "../finding.js";
6
7
 
7
8
  export function checkBlockRequires(article: BlogArticleInput, pluginBlocks: readonly FoundBlock[]): QualityFinding[] {
@@ -41,3 +42,27 @@ function hasFrontmatterKey(article: BlogArticleInput, key: string): boolean {
41
42
  return article.fields[key] !== undefined;
42
43
  }
43
44
  }
45
+
46
+ /**
47
+ * Directive lines the renderer would not turn into a block (BL-6 for directives): a name no directive
48
+ * plugin renders (it would show as a paragraph `::chrat{…}`), or attributes it cannot read. Checked
49
+ * only when the app registers a directive plugin; otherwise `::` lines are the app's own business.
50
+ */
51
+ export function checkDirectives(blocks: readonly Block[], pluginBlocks: readonly FoundBlock[], plugins: readonly BlockPlugin[]): QualityFinding[] {
52
+ const names = plugins.filter((plugin) => plugin.syntax === "directive").map((plugin) => plugin.type);
53
+ if (names.length === 0) return [];
54
+ const unknown = blocks.flatMap((block) => {
55
+ if (block.kind !== "directive") return [];
56
+ const directive = parseDirectiveLine(block.text);
57
+ if (directive === null || names.includes(directive.name)) return [];
58
+ return [finding(`::${directive.name} is not a directive this blog renders; use one of: ${names.join(", ")}`, block.line)];
59
+ });
60
+ const unreadable = pluginBlocks
61
+ .filter((block) => block.syntax === "directive" && block.attributes === null)
62
+ .map((block) => finding(`the attributes of ::${block.type} cannot be read; write them as key="value" pairs, each key once`, block.line));
63
+ return [...unknown, ...unreadable].sort((a, b) => (a.line ?? 0) - (b.line ?? 0));
64
+ }
65
+
66
+ function finding(message: string, line: number): QualityFinding {
67
+ return { rule: "block-directive", severity: "error", message, line };
68
+ }
@@ -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. */
@@ -0,0 +1,34 @@
1
+ // An article or term as Markdown for agents (`Accept: text/markdown`): the stored Markdown in a frame
2
+ // that carries what the HTML page shows around it (title, description, the day the facts were checked,
3
+ // the summary, sources and FAQ). Exact by construction: no HTML round trip.
4
+ import type { BlogArticle } from "../contract.js";
5
+ import { en } from "../messages/en.js";
6
+ import { replaceArticleBlocks, type BlockPlugin } from "./render-article.js";
7
+
8
+ export type ArticleMarkdownInput = Pick<BlogArticle, "title" | "description" | "summary" | "bodyMarkdown" | "sources" | "faq" | "currentAsOf" | "fields">;
9
+
10
+ export interface ArticleMarkdownOptions {
11
+ /** The app's block plugins; a block whose plugin has `markdown` is replaced by its output. */
12
+ readonly blocks?: readonly BlockPlugin[];
13
+ /** The pages' copy for the frame's labels; English by default. */
14
+ readonly messages?: Pick<(typeof en)["pages"], "summary" | "sources" | "faq" | "currentAsOf">;
15
+ }
16
+
17
+ export function toArticleMarkdown(article: ArticleMarkdownInput, options: ArticleMarkdownOptions = {}): string {
18
+ const messages = options.messages ?? en.pages;
19
+ const body = replaceArticleBlocks(article.bodyMarkdown.trim(), options.blocks ?? [], { currentAsOf: article.currentAsOf, fields: article.fields });
20
+ const parts = [`# ${article.title}`, article.description, `${messages.currentAsOf}: ${article.currentAsOf}`];
21
+ if (article.summary !== null) parts.push(`> **${messages.summary}:** ${article.summary}`);
22
+ parts.push(body);
23
+ if (article.sources.length > 0) {
24
+ parts.push(`## ${messages.sources}`, article.sources.map((source) => `- [${escapeLinkText(source.name)}](${source.url})`).join("\n"));
25
+ }
26
+ if (article.faq.length > 0) {
27
+ parts.push(`## ${messages.faq}`, ...article.faq.flatMap((entry) => [`### ${entry.question}`, entry.answer]));
28
+ }
29
+ return `${parts.join("\n\n")}\n`;
30
+ }
31
+
32
+ function escapeLinkText(text: string): string {
33
+ return text.replace(/[[\]\\]/g, (char) => `\\${char}`);
34
+ }
@@ -11,17 +11,25 @@ export {
11
11
  type ImageProblem,
12
12
  type ImageVerdict,
13
13
  } from "./images.js";
14
+ export { toArticleMarkdown, type ArticleMarkdownInput, type ArticleMarkdownOptions } from "./article-markdown.js";
14
15
  export { DEFAULT_WORDS_PER_MINUTE, getReadingMinutes } from "./reading-time.js";
15
16
  export {
16
17
  findArticleBlocks,
18
+ parseDirectiveAttributes,
19
+ parseDirectiveLine,
20
+ EXTERNAL_LINK_MARKERS,
17
21
  renderArticle,
22
+ replaceArticleBlocks,
18
23
  type ArticleBlock,
24
+ type BlockAttributes,
25
+ type BlockSyntax,
19
26
  type ArticleHeading,
20
27
  type ArticleSegment,
21
28
  type BlockArticle,
22
29
  type BlockOutput,
23
30
  type BlockPlugin,
24
31
  type BlogRenderMessages,
32
+ type ExternalLinkMarker,
25
33
  type FoundBlock,
26
34
  type RenderArticleOptions,
27
35
  type RenderedArticle,