@softure-ai/blog 0.1.5 → 0.1.7

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 (92) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +142 -15
  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 +13 -6
  8. package/dist/cli/run.d.ts.map +1 -1
  9. package/dist/cli/run.js +131 -94
  10. package/dist/cli/run.js.map +1 -1
  11. package/dist/contract.d.ts +4 -0
  12. package/dist/contract.d.ts.map +1 -1
  13. package/dist/db/articles.d.ts +10 -1
  14. package/dist/db/articles.d.ts.map +1 -1
  15. package/dist/db/articles.js +36 -4
  16. package/dist/db/articles.js.map +1 -1
  17. package/dist/db/history.d.ts +33 -0
  18. package/dist/db/history.d.ts.map +1 -0
  19. package/dist/db/history.js +66 -0
  20. package/dist/db/history.js.map +1 -0
  21. package/dist/db/publish-run.d.ts +11 -0
  22. package/dist/db/publish-run.d.ts.map +1 -1
  23. package/dist/db/publish-run.js +14 -4
  24. package/dist/db/publish-run.js.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/next/context.js +2 -2
  27. package/dist/next/context.js.map +1 -1
  28. package/dist/options.d.ts.map +1 -1
  29. package/dist/options.js +3 -1
  30. package/dist/options.js.map +1 -1
  31. package/dist/pages/accept.d.ts +7 -0
  32. package/dist/pages/accept.d.ts.map +1 -0
  33. package/dist/pages/accept.js +34 -0
  34. package/dist/pages/accept.js.map +1 -0
  35. package/dist/proxy/index.d.ts +15 -0
  36. package/dist/proxy/index.d.ts.map +1 -1
  37. package/dist/proxy/index.js +54 -4
  38. package/dist/proxy/index.js.map +1 -1
  39. package/dist/quality/catalog.d.ts.map +1 -1
  40. package/dist/quality/catalog.js +4 -1
  41. package/dist/quality/catalog.js.map +1 -1
  42. package/dist/quality/check-article.d.ts.map +1 -1
  43. package/dist/quality/check-article.js +2 -1
  44. package/dist/quality/check-article.js.map +1 -1
  45. package/dist/quality/options.d.ts.map +1 -1
  46. package/dist/quality/options.js +3 -1
  47. package/dist/quality/options.js.map +1 -1
  48. package/dist/quality/rules/blocks.d.ts +8 -1
  49. package/dist/quality/rules/blocks.d.ts.map +1 -1
  50. package/dist/quality/rules/blocks.js +26 -0
  51. package/dist/quality/rules/blocks.js.map +1 -1
  52. package/dist/render/article-markdown.d.ts +12 -0
  53. package/dist/render/article-markdown.d.ts.map +1 -0
  54. package/dist/render/article-markdown.js +21 -0
  55. package/dist/render/article-markdown.js.map +1 -0
  56. package/dist/render/index.d.ts +2 -1
  57. package/dist/render/index.d.ts.map +1 -1
  58. package/dist/render/index.js +2 -1
  59. package/dist/render/index.js.map +1 -1
  60. package/dist/render/render-article.d.ts +40 -4
  61. package/dist/render/render-article.d.ts.map +1 -1
  62. package/dist/render/render-article.js +132 -10
  63. package/dist/render/render-article.js.map +1 -1
  64. package/dist/server/index.d.ts +2 -1
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +1 -0
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/sitemap.js +2 -2
  69. package/dist/sitemap.js.map +1 -1
  70. package/module.json +1 -1
  71. package/package.json +7 -3
  72. package/skill/references/rules.md +2 -1
  73. package/src/cli/report.ts +182 -0
  74. package/src/cli/run.ts +132 -100
  75. package/src/contract.ts +9 -1
  76. package/src/db/articles.ts +39 -4
  77. package/src/db/history.ts +83 -0
  78. package/src/db/publish-run.ts +24 -4
  79. package/src/index.ts +1 -1
  80. package/src/next/context.ts +2 -2
  81. package/src/options.ts +6 -2
  82. package/src/pages/accept.ts +39 -0
  83. package/src/proxy/index.ts +62 -4
  84. package/src/quality/catalog.ts +4 -1
  85. package/src/quality/check-article.ts +2 -1
  86. package/src/quality/options.ts +6 -2
  87. package/src/quality/rules/blocks.ts +26 -1
  88. package/src/render/article-markdown.ts +34 -0
  89. package/src/render/index.ts +6 -0
  90. package/src/render/render-article.ts +170 -16
  91. package/src/server/index.ts +2 -0
  92. package/src/sitemap.ts +2 -2
package/src/options.ts CHANGED
@@ -50,8 +50,12 @@ function isFieldsSchema(value: unknown): value is BlogFieldsSchema {
50
50
 
51
51
  function isBlockPlugin(value: unknown): value is BlockPlugin {
52
52
  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";
53
+ const candidate = value as { type?: unknown; syntax?: unknown; render?: unknown; markdown?: unknown };
54
+ return (
55
+ typeof candidate.type === "string" && KEBAB.test(candidate.type) && typeof candidate.render === "function" &&
56
+ (candidate.syntax === undefined || candidate.syntax === "fence" || candidate.syntax === "directive") &&
57
+ (candidate.markdown === undefined || typeof candidate.markdown === "function")
58
+ );
55
59
  }
56
60
 
57
61
  /** How long the pages cache their reads by default, in seconds; the app's `revalidate` should match. */
@@ -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
+ }
@@ -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
- import { getSharedDatabase } from "@softure-ai/db";
13
- import { findArticleBySlug, findSlugRedirect, type BlogContext } from "../db/articles.js";
17
+ import { getConfiguredDatabase } from "@softure-ai/db";
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
 
@@ -25,7 +34,7 @@ export interface BlogRedirectsOptions extends CachedDeciderOptions {
25
34
  function createDefaultContext(config: SoftureConfig): () => Promise<BlogContext> {
26
35
  return async () => {
27
36
  if (config.database === null) throw new Error("@softure-ai/blog: softure.config.ts has no database; the blog needs one");
28
- const { db } = await getSharedDatabase(config.database.url);
37
+ const { db } = await getConfiguredDatabase(config.database);
29
38
  return { db, clock: systemClock, config };
30
39
  };
31
40
  }
@@ -64,3 +73,52 @@ export function createBlogRedirects(config: SoftureConfig, options: BlogRedirect
64
73
  return null;
65
74
  };
66
75
  }
76
+
77
+ export type BlogMarkdown = (request: Request) => Promise<Response | null>;
78
+
79
+ export interface BlogMarkdownOptions {
80
+ /** The store's context; the shared database handle of `config.database` by default (tests pass PGlite). */
81
+ readonly getContext?: () => Promise<BlogContext>;
82
+ /** Where a failed read is reported; `console.error` by default. The request then goes on to the page. */
83
+ readonly onError?: (message: string) => void;
84
+ }
85
+
86
+ const MARKDOWN_HEADERS = {
87
+ "content-type": "text/markdown; charset=utf-8",
88
+ // One address, two representations. `private`: a shared cache must not hand Markdown to a browser.
89
+ vary: "Accept",
90
+ "cache-control": "private, max-age=0, must-revalidate",
91
+ };
92
+
93
+ /**
94
+ * Answers a GET or HEAD of a published article or term whose `Accept` asks for Markdown
95
+ * (`prefersMarkdown`) with the text as Markdown (`toArticleMarkdown`, the app's block plugins giving
96
+ * their Markdown form). `null` for everything else, and when the read fails: the page answers then.
97
+ */
98
+ export function createBlogMarkdown(config: SoftureConfig, options: BlogMarkdownOptions = {}): BlogMarkdown {
99
+ const routes = getBlogRoutes(config);
100
+ const reservedSlugs = getBlogReservedSlugs(config);
101
+ const getContext = options.getContext ?? createDefaultContext(config);
102
+ const onError = options.onError ?? ((message: string) => console.error(message));
103
+ const { blocks } = getBlogOptions(config);
104
+ const messages = getBlogMessages(config).pages;
105
+
106
+ return async (request) => {
107
+ if (request.method !== "GET" && request.method !== "HEAD") return null;
108
+ if (!prefersMarkdown(request.headers.get("accept"))) return null;
109
+ const url = new URL(request.url);
110
+ const match = matchBlogPath(url.pathname, routes, reservedSlugs);
111
+ if (match === null) return null;
112
+ let article;
113
+ try {
114
+ article = await getPublishedArticle(await getContext(), match.slug);
115
+ } catch (error) {
116
+ onError(`@softure-ai/blog: reading ${url.pathname} as Markdown failed: ${error instanceof Error ? error.message : String(error)}`);
117
+ return null;
118
+ }
119
+ if (article?.kind !== match.kind) return null;
120
+ const body = toArticleMarkdown(article, { blocks, messages });
121
+ const headers = { ...MARKDOWN_HEADERS, "x-markdown-tokens": String(Math.ceil(body.length / 4)) };
122
+ return new Response(request.method === "HEAD" ? null : body, { status: 200, headers });
123
+ };
124
+ }
@@ -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 }));
@@ -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) =>
@@ -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
+ }
@@ -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,11 +11,17 @@ 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,
17
20
  renderArticle,
21
+ replaceArticleBlocks,
18
22
  type ArticleBlock,
23
+ type BlockAttributes,
24
+ type BlockSyntax,
19
25
  type ArticleHeading,
20
26
  type ArticleSegment,
21
27
  type BlockArticle,
@@ -36,6 +36,10 @@
36
36
  // A fenced block whose type an app registers (```` ```chart ````) is rendered by the app's plugin, as
37
37
  // HTML or as a node (e.g. a React server component). Plugin output is the app's own code and is
38
38
  // trusted as is. Only top-level fences are plugin blocks; a fence inside a list or a quote stays code.
39
+ //
40
+ // A plugin with `syntax: "directive"` renders a leaf directive instead: a line of its own,
41
+ // `::chart{type="wealth" scenario="…"}` (braces optional), with double-quoted values and each key
42
+ // once. Like fences, only top-level lines count; a directive in a list, a quote or code stays text.
39
43
  import MarkdownIt, { type MarkdownIt as Markdown, type StateCore, type Token } from "markdown-it";
40
44
  import footnote from "markdown-it-footnote";
41
45
  import type { BlogFields } from "../contract.js";
@@ -61,12 +65,24 @@ export interface BlockArticle {
61
65
  readonly fields?: BlogFields;
62
66
  }
63
67
 
68
+ /** How a block is written: a fence (```` ```chart ````) or a leaf directive (`::chart{…}`). */
69
+ export type BlockSyntax = "fence" | "directive";
70
+
71
+ /** A directive's attributes, or `null` when its braces cannot be read. Always `{}` for a fence. */
72
+ export type BlockAttributes = Readonly<Record<string, string>> | null;
73
+
64
74
  export interface ArticleBlock {
65
- /** The block type, the first word of the fence's info string. */
75
+ /** The block type: the first word of the fence's info string, or the directive's name. */
66
76
  readonly type: string;
67
- /** The rest of the info string, trimmed: ```` ```chart wealth ```` → `"wealth"`. */
77
+ readonly syntax: BlockSyntax;
78
+ /**
79
+ * Fence: the rest of the info string, trimmed (```` ```chart wealth ```` → `"wealth"`). Directive:
80
+ * the text inside the braces, trimmed.
81
+ */
68
82
  readonly info: string;
69
- /** The body of the fence, as written. */
83
+ /** `key="value"` pairs of a directive; `{}` for a fence. */
84
+ readonly attributes: BlockAttributes;
85
+ /** Fence: its body, as written. Directive: the whole line, trimmed. */
70
86
  readonly content: string;
71
87
  readonly article: BlockArticle;
72
88
  }
@@ -78,6 +94,8 @@ export type BlockOutput<TNode = unknown> =
78
94
  export interface BlockPlugin<TNode = unknown> {
79
95
  /** Lower-case kebab-case, e.g. `chart`. */
80
96
  readonly type: string;
97
+ /** Which blocks the plugin renders: fences (the default) or leaf directives. */
98
+ readonly syntax?: BlockSyntax;
81
99
  /**
82
100
  * The frontmatter keys the block reads (`current_as_of` or keys of the app's `fields`), so the
83
101
  * quality gate can report a block whose article lacks them.
@@ -85,6 +103,11 @@ export interface BlockPlugin<TNode = unknown> {
85
103
  readonly requires?: readonly string[];
86
104
  /** Throws only on a bug; a block that cannot render returns its own error markup. */
87
105
  readonly render: (block: ArticleBlock) => BlockOutput<TNode>;
106
+ /**
107
+ * The block as Markdown for agents (`toArticleMarkdown`, `Accept: text/markdown`): a table or a
108
+ * sentence. Without it the block's source stays in the Markdown.
109
+ */
110
+ readonly markdown?: (block: ArticleBlock) => string;
88
111
  }
89
112
 
90
113
  export type ArticleSegment<TNode = unknown> =
@@ -127,8 +150,10 @@ export interface RenderedArticle<TNode = unknown> {
127
150
 
128
151
  export interface FoundBlock {
129
152
  readonly type: string;
153
+ readonly syntax: BlockSyntax;
130
154
  readonly info: string;
131
- /** 1-based line of the opening fence. */
155
+ readonly attributes: BlockAttributes;
156
+ /** 1-based line of the opening fence or of the directive. */
132
157
  readonly line: number;
133
158
  readonly requires: readonly string[];
134
159
  }
@@ -141,6 +166,61 @@ const FOOTNOTES_HEADING_ID = "footnotes";
141
166
  // Ids the renderer gives footnotes; a heading whose slug equals one of them gets a suffix.
142
167
  const FOOTNOTE_ID = /^(?:footnotes|fn(?:ref)?-\d+(?:-\d+)?)$/;
143
168
  const BLOCK_TOKEN = "blog_block";
169
+ const DIRECTIVE_LINE = /^::([a-z][a-z0-9]*(?:-[a-z0-9]+)*)(?:\{(.*)\})?$/;
170
+ const DIRECTIVE_START = /^::[a-z]/;
171
+ const ATTRIBUTE_KEY_START = /[a-z]/;
172
+ const ATTRIBUTE_KEY_CHAR = /[a-z0-9_-]/;
173
+ const WHITESPACE = /\s/;
174
+
175
+ interface BlockMeta {
176
+ readonly type: string;
177
+ readonly syntax: BlockSyntax;
178
+ readonly info: string;
179
+ readonly attributes: BlockAttributes;
180
+ }
181
+
182
+ /** The `key="value"` pairs inside a directive's braces; `null` when anything else is there or a key repeats. */
183
+ export function parseDirectiveAttributes(text: string): BlockAttributes {
184
+ // A hand scanner, linear in the text: a regex here runs on author input and backtracks on long runs.
185
+ const attributes: Record<string, string> = {};
186
+ let index = 0;
187
+ const skipWhitespace = (): void => {
188
+ while (index < text.length && WHITESPACE.test(text.charAt(index))) index += 1;
189
+ };
190
+ skipWhitespace();
191
+ while (index < text.length) {
192
+ if (!ATTRIBUTE_KEY_START.test(text.charAt(index))) return null;
193
+ const keyStart = index;
194
+ while (index < text.length && ATTRIBUTE_KEY_CHAR.test(text.charAt(index))) index += 1;
195
+ const key = text.slice(keyStart, index);
196
+ if (text.charAt(index) !== "=" || text.charAt(index + 1) !== '"') return null;
197
+ const valueEnd = text.indexOf('"', index + 2);
198
+ if (valueEnd === -1) return null;
199
+ if (Object.hasOwn(attributes, key)) return null;
200
+ attributes[key] = text.slice(index + 2, valueEnd);
201
+ index = valueEnd + 1;
202
+ skipWhitespace();
203
+ }
204
+ return attributes;
205
+ }
206
+
207
+ /**
208
+ * A line read as a leaf directive: its name, the text inside the braces and the attributes. `null`
209
+ * for a line that is not one (a name must follow `::`); a directive whose braces do not close keeps
210
+ * its name and gets `null` attributes.
211
+ */
212
+ export function parseDirectiveLine(line: string): { readonly name: string; readonly info: string; readonly attributes: BlockAttributes } | null {
213
+ const trimmed = line.trim();
214
+ if (!DIRECTIVE_START.test(trimmed)) return null;
215
+ const match = DIRECTIVE_LINE.exec(trimmed);
216
+ if (match === null) {
217
+ const name = /^::([a-z][a-z0-9-]*)/.exec(trimmed)?.[1] ?? "";
218
+ const rest = trimmed.slice(2 + name.length).trim();
219
+ return { name, info: rest.replace(/^\{/, "").trim(), attributes: null };
220
+ }
221
+ const info = (match[2] ?? "").trim();
222
+ return { name: match[1] ?? "", info, attributes: parseDirectiveAttributes(info) };
223
+ }
144
224
 
145
225
  function getDefaultTermHref(slug: string): string {
146
226
  return `/blog/glossary/${slug}`;
@@ -354,26 +434,77 @@ function addBlockTokens(md: Markdown, types: ReadonlySet<string>): void {
354
434
  const [type = "", ...rest] = token.info.trim().split(/\s+/);
355
435
  if (!types.has(type)) continue;
356
436
  token.type = BLOCK_TOKEN;
357
- token.meta = { type, info: rest.join(" ") };
437
+ const meta: BlockMeta = { type, syntax: "fence", info: rest.join(" "), attributes: {} };
438
+ token.meta = { block: meta };
358
439
  }
359
440
  });
360
441
  }
361
442
 
362
- function getPluginsByType<TNode>(plugins: readonly BlockPlugin<TNode>[]): Map<string, BlockPlugin<TNode>> {
363
- const byType = new Map<string, BlockPlugin<TNode>>();
443
+ /**
444
+ * A block rule for top-level lines `::name{…}` of registered directive names. Before `paragraph`
445
+ * and allowed to end one, so a directive right under a paragraph line is still a block.
446
+ */
447
+ function addDirectiveRule(md: Markdown, names: ReadonlySet<string>): void {
448
+ if (names.size === 0) return;
449
+ md.block.ruler.before(
450
+ "paragraph",
451
+ "blog_directive",
452
+ (state, startLine, _endLine, silent) => {
453
+ // Four spaces are an indented code block; a nested block (list, quote) is not top level.
454
+ if (state.level !== 0 || state.blkIndent !== 0 || (state.sCount[startLine] ?? 0) - state.blkIndent >= 4) return false;
455
+ const line = state.src.slice((state.bMarks[startLine] ?? 0) + (state.tShift[startLine] ?? 0), state.eMarks[startLine]);
456
+ const directive = parseDirectiveLine(line);
457
+ if (directive === null || !names.has(directive.name)) return false;
458
+ if (!silent) {
459
+ const token = state.push(BLOCK_TOKEN, "", 0);
460
+ token.block = true;
461
+ token.content = line.trim();
462
+ token.map = [startLine, startLine + 1];
463
+ const meta: BlockMeta = { type: directive.name, syntax: "directive", info: directive.info, attributes: directive.attributes };
464
+ token.meta = { block: meta };
465
+ }
466
+ state.line = startLine + 1;
467
+ return true;
468
+ },
469
+ { alt: ["paragraph"] },
470
+ );
471
+ }
472
+
473
+ /** The block a `blog_block` token stands for; set by `addBlockTokens` and `addDirectiveRule`. */
474
+ function readBlockMeta(token: Token): BlockMeta {
475
+ return (token.meta as { block: BlockMeta }).block;
476
+ }
477
+
478
+ /** Plugins by `syntax:type`. */
479
+ type PluginRegistry<TNode> = Map<string, BlockPlugin<TNode>>;
480
+
481
+ function getPluginKey(syntax: BlockSyntax, type: string): string {
482
+ return `${syntax}:${type}`;
483
+ }
484
+
485
+ function getPluginsByType<TNode>(plugins: readonly BlockPlugin<TNode>[]): PluginRegistry<TNode> {
486
+ const byType: PluginRegistry<TNode> = new Map();
364
487
  for (const plugin of plugins) {
365
488
  if (!BLOCK_TYPE.test(plugin.type)) {
366
489
  throw new Error(`Block plugin type "${plugin.type}" must be lower-case kebab-case, e.g. "chart".`);
367
490
  }
368
- if (byType.has(plugin.type)) throw new Error(`Block plugin type "${plugin.type}" is registered twice.`);
369
- byType.set(plugin.type, plugin);
491
+ const syntax = plugin.syntax ?? "fence";
492
+ const key = getPluginKey(syntax, plugin.type);
493
+ if (byType.has(key)) {
494
+ throw new Error(syntax === "fence" ? `Block plugin type "${plugin.type}" is registered twice.` : `Block plugin type "${plugin.type}" (directive) is registered twice.`);
495
+ }
496
+ byType.set(key, plugin);
370
497
  }
371
498
  return byType;
372
499
  }
373
500
 
501
+ function getTypes<TNode>(plugins: PluginRegistry<TNode>, syntax: BlockSyntax): Set<string> {
502
+ return new Set([...plugins.values()].filter((plugin) => (plugin.syntax ?? "fence") === syntax).map((plugin) => plugin.type));
503
+ }
504
+
374
505
  function createMarkdown<TNode>(
375
506
  options: RenderArticleOptions<TNode>,
376
- plugins: ReadonlyMap<string, BlockPlugin<TNode>>,
507
+ plugins: PluginRegistry<TNode>,
377
508
  state: RenderState,
378
509
  ): Markdown {
379
510
  const messages = options.messages ?? en.render;
@@ -383,7 +514,8 @@ function createMarkdown<TNode>(
383
514
  addImages(md, options.images);
384
515
  addFootnoteMarkup(md, messages);
385
516
  addExternalLinks(md, options.siteHosts ?? [], messages);
386
- addBlockTokens(md, new Set(plugins.keys()));
517
+ addBlockTokens(md, getTypes(plugins, "fence"));
518
+ addDirectiveRule(md, getTypes(plugins, "directive"));
387
519
  addHeadingIds(md, state);
388
520
  addGlossaryLinks(
389
521
  md,
@@ -442,10 +574,10 @@ export function renderArticle<TNode = unknown>(markdown: string, options: Render
442
574
  if (token.type !== BLOCK_TOKEN) return;
443
575
  addHtmlSegment(segments, md.renderer.render(tokens.slice(start, index), md.options, env));
444
576
  start = index + 1;
445
- const meta = token.meta as { type: string; info: string };
446
- const plugin = plugins.get(meta.type);
577
+ const meta = readBlockMeta(token);
578
+ const plugin = plugins.get(getPluginKey(meta.syntax, meta.type));
447
579
  if (plugin === undefined) return;
448
- const output = plugin.render({ type: meta.type, info: meta.info, content: token.content, article: options.article ?? {} });
580
+ const output = plugin.render({ ...meta, content: token.content, article: options.article ?? {} });
449
581
  if (output.kind === "html") {
450
582
  addHtmlSegment(segments, output.html);
451
583
  } else {
@@ -481,7 +613,29 @@ export function findArticleBlocks(markdown: string, plugins: readonly BlockPlugi
481
613
  const md = createMarkdown({ blocks: plugins }, byType, { headings: [], linkedTerms: [] });
482
614
  return md.parse(markdown, {}).flatMap((token) => {
483
615
  if (token.type !== BLOCK_TOKEN) return [];
484
- const meta = token.meta as { type: string; info: string };
485
- return [{ type: meta.type, info: meta.info, line: (token.map?.[0] ?? 0) + 1, requires: byType.get(meta.type)?.requires ?? [] }];
616
+ const meta = readBlockMeta(token);
617
+ const requires = byType.get(getPluginKey(meta.syntax, meta.type))?.requires ?? [];
618
+ return [{ type: meta.type, syntax: meta.syntax, info: meta.info, attributes: meta.attributes, line: (token.map?.[0] ?? 0) + 1, requires }];
486
619
  });
487
620
  }
621
+
622
+ /**
623
+ * The text with each plugin block that has a Markdown form (`BlockPlugin.markdown`) replaced by it;
624
+ * other blocks keep their source. For agents that read the article as Markdown.
625
+ */
626
+ export function replaceArticleBlocks(markdown: string, plugins: readonly BlockPlugin[], article: BlockArticle = {}): string {
627
+ if (!plugins.some((plugin) => plugin.markdown !== undefined)) return markdown;
628
+ const byType = getPluginsByType(plugins);
629
+ const md = createMarkdown({ blocks: plugins }, byType, { headings: [], linkedTerms: [] });
630
+ const lines = markdown.split("\n");
631
+ const blocks = md.parse(markdown, {}).filter((token) => token.type === BLOCK_TOKEN && token.map !== null);
632
+ for (const token of blocks.reverse()) {
633
+ const meta = readBlockMeta(token);
634
+ const plugin = byType.get(getPluginKey(meta.syntax, meta.type));
635
+ const [start = 0, end = start] = token.map ?? [];
636
+ if (plugin?.markdown === undefined) continue;
637
+ const replacement = plugin.markdown({ ...meta, content: token.content, article });
638
+ lines.splice(start, end - start, ...replacement.split("\n"));
639
+ }
640
+ return lines.join("\n");
641
+ }
@@ -10,7 +10,9 @@ export {
10
10
  publishArticle,
11
11
  type BlogContext,
12
12
  type ListArticlesFilter,
13
+ type PublishArticleOptions,
13
14
  } from "../db/articles.js";
15
+ export { articleHistorySchema, parseArticleHistory, type ArticleHistory, type ArticleHistoryMap } from "../db/history.js";
14
16
  export {
15
17
  runBlogPublish,
16
18
  type ArticleFile,
package/src/sitemap.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // one query on the shared database, not from the pages' Next cache. Crawlers read a sitemap rarely.
5
5
  import { systemClock, type SoftureConfig } from "@softure-ai/core";
6
6
  import { getSoftureConfig } from "@softure-ai/core/next";
7
- import { getSharedDatabase } from "@softure-ai/db";
7
+ import { getConfiguredDatabase } from "@softure-ai/db";
8
8
  import { listArticles } from "./db/articles.js";
9
9
  import { getBlogSitemapEntries, type BlogSitemapEntry } from "./discovery/sitemap.js";
10
10
  import { getBlogOptions, getBlogRoutes } from "./server/options.js";
@@ -15,7 +15,7 @@ export async function readBlogSitemap(config: SoftureConfig): Promise<BlogSitema
15
15
  // Unreachable for a validated config: the module has a database schema.
16
16
  throw new Error("@softure-ai/blog: softure.config.ts has no database; the blog sitemap needs one");
17
17
  }
18
- const { db } = await getSharedDatabase(config.database.url);
18
+ const { db } = await getConfiguredDatabase(config.database);
19
19
  const texts = await listArticles({ db, clock: systemClock, config });
20
20
  const routes = getBlogRoutes(config);
21
21
  return getBlogSitemapEntries({