@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/CHANGELOG.md CHANGED
@@ -4,6 +4,36 @@ Newest first. Each version lists what changed for an app that uses `@softure-ai/
4
4
  production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
5
5
  "Definition of done"). Versions before the first one below are described in their GitHub Releases (`blog@x.y.z`).
6
6
 
7
+ ## 0.1.8
8
+
9
+ - `externalLinkMarker` (`blog({ ... })`) and `renderArticle({ externalMarker })`: `"icon-and-text"`
10
+ (default, as before), `"text"` (the visually hidden "opens in a new tab" only) or `"none"`. External
11
+ links keep `target`, `rel` and the `blog-external` class either way.
12
+ - The quality gate takes the paths of articles and terms from the blog's `routes`; `quality.paths`
13
+ (each key optional now) only overrides them. `QualitySettings.paths` holds the resolved pair;
14
+ `QualityOptions.paths` is the override alone.
15
+ - `/next` exports `buildArticleMetadata`, `buildTermMetadata`, `buildBlogIndexMetadata`,
16
+ `buildGlossaryIndexMetadata`, `buildMethodMetadata`, `buildArticleJsonLd`, `buildTermJsonLd`,
17
+ `buildGlossaryJsonLd` and `getCrumbLabels`: the ready-made pages' metadata and JSON-LD for an app
18
+ with its own page components, without a database read.
19
+ - `gonePage` (`blog({ ... })`): `links` add further ways on to the 410 page, `render` writes its whole
20
+ body. `buildGonePage` takes `links`.
21
+ - An imported history keeps its timestamps to the microsecond. Breaking for code that builds an
22
+ `ArticleHistory` by hand: its timestamps are ISO strings now, not `Date` (`parseArticleHistory`
23
+ callers see no change).
24
+
25
+ ## 0.1.7
26
+
27
+ - Block plugins with `syntax: "directive"` render top-level `::name{key="value"}` lines with parsed
28
+ `attributes`; the quality gate checks their `requires` and reports an unknown directive or unreadable
29
+ attributes (`block-directive`). `ArticleBlock` and `FoundBlock` carry `syntax` and `attributes`.
30
+ - `createBlogMarkdown` (`/proxy`) answers an article or term asked for with `Accept: text/markdown` with
31
+ `toArticleMarkdown`; a block plugin may give its Markdown form (`markdown`).
32
+ - `softure-blog publish --stdin` reads one file (`--name`) or a JSON bundle (files and an optional
33
+ history) from standard input; `--format lines` prints a stable `blog|<key>|…` contract.
34
+ - `publish --history <file.json>` (and `runBlogPublish({ history })`) imports `published_at`, `updated_at`
35
+ and old slugs on the first publish of each article, for an app moving its existing blog in.
36
+
7
37
  ## 0.1.6
8
38
 
9
39
  - Adapters and commands use the configured database handle.
package/README.md CHANGED
@@ -11,12 +11,15 @@ editor and no CMS, a text changes only through a commit and `softure-blog publis
11
11
  - `blog.articles` and `blog.slug_history` with database constraints for every invariant that fits one.
12
12
  - `softure-blog publish`: a dry run by default; with `--commit`, all files or none; unchanged files are
13
13
  skipped by their content hash; a slug change keeps the old slug as a redirect; one pillar per cluster.
14
+ Files from paths or from standard input (`--stdin`, for a release through an ssh pipe), a line
15
+ contract for scripts (`--format lines`), and the dates and old slugs of an existing blog imported on
16
+ the first publish (`--history`).
14
17
  - Read functions for the pages: `getPublishedArticle`, `findArticleBySlug`, `findSlugRedirect`,
15
18
  `listArticles`.
16
19
  - `renderArticle(markdown, options)`: the body as safe HTML on the server (no raw HTML, safe link
17
20
  schemes only, marked external links, images under the app's image policy), heading ids and an
18
21
  optional table of contents, glossary links on the first mention of a term, block plugins for the
19
- app's own fenced blocks, reading time.
22
+ app's own fenced blocks and `::directive{…}` lines, reading time.
20
23
  - Pages, each mounted with one re-export line (`@softure-ai/blog/next`): the listing grouped by cluster
21
24
  with the pillar first, an article (dates, summary, contents, FAQ, sources, signature, disclaimer,
22
25
  `BlogPosting`/`BreadcrumbList`/`FAQPage` JSON-LD), the glossary index and a term page (`DefinedTerm`,
@@ -24,7 +27,8 @@ editor and no CMS, a text changes only through a commit and `softure-blog publis
24
27
  Their canonical, Open Graph, JSON-LD and feed URLs follow `@softure-ai/seo`'s origin, host and
25
28
  trailing-slash rule when the app lists `seo()` (core's `getSiteUrls`), and `appOrigin` otherwise.
26
29
  - `createBlogRedirects` (`@softure-ai/blog/proxy`): 301 from an old slug, 410 for a withdrawn text,
27
- in the app's `proxy.ts`.
30
+ in the app's `proxy.ts`; `createBlogMarkdown` answers an article or term asked for with
31
+ `Accept: text/markdown` with its Markdown (`toArticleMarkdown`), for agents.
28
32
  - `@softure-ai/blog/styles.css`: the pages and the rendered body on the `--sft-*` tokens.
29
33
  - Discovery: an RSS 2.0 feed (`serveBlogRss`), "read next" under every article (its cluster first, the
30
34
  pillar on top), and, with `@softure-ai/seo` (optional): sitemap entries with each text's real
@@ -69,7 +73,7 @@ blog({
69
73
  fields: z.object({ scenario: z.string().regex(/^[a-z]=\d+(&[a-z]=\d+)*$/).optional() }),
70
74
  // The pages' brand: title suffix, signature, JSON-LD author and publisher, OG card colours (hex)
71
75
  // and fonts (§8). Default: none (no suffix, no author, the ui theme's dark colours, next/og's font).
72
- brand: { name: "FIRE Tracker", colors: { background: "#0b0b0c", foreground: "#f5f5f5", accent: "#7aa2f7" } },
76
+ brand: { name: "Example", colors: { background: "#0b0b0c", foreground: "#f5f5f5", accent: "#7aa2f7" } },
73
77
  // Mount the method page at routes.method. Default: false (the route answers 404).
74
78
  methodPage: true,
75
79
  // A note under every article and term, per locale (en required). Default: none.
@@ -80,6 +84,11 @@ blog({
80
84
  blocks: [],
81
85
  // Hosts of the app besides APP_ORIGIN's and seo's canonical host, whose links are not external. Default: [].
82
86
  siteHosts: ["www.example.com"],
87
+ // What follows an external link in a body: "icon-and-text" (a ↗ hidden from screen readers and a
88
+ // visually hidden "opens in a new tab"), "text" (the hidden words only) or "none". Default: "icon-and-text".
89
+ externalLinkMarker: "icon-and-text",
90
+ // The 410 page of a withdrawn text: extra links, or the app's own body (§4). Default: one link to the listing.
91
+ gonePage: { links: [] },
83
92
  // Which images bodies may show: site paths and https images on these hosts (subdomains included),
84
93
  // with a width and height the app knows. Used by the pages and the quality gate. Default: none
85
94
  // (every image renders as its alt text and the gate refuses it).
@@ -112,9 +121,9 @@ blog({
112
121
  // a global RegExp; wordPattern (from @softure-ai/blog) adds the i flag and word edges in any alphabet
113
122
  phrases: [{ id: "finance-cliche", pattern: wordPattern("in the world of finance"), message: "say what happens instead" }],
114
123
  },
115
- limits: { words: { article: { min: 600, max: 4000 } }, answerWords: 70 }, // FIRE's values are the defaults
124
+ limits: { words: { article: { min: 600, max: 4000 } }, answerWords: 70 }, // the defaults
116
125
  severity: { exclamation: "error", "lead-number": "off" }, // per rule: "error", "warning" or "off"
117
- paths: { articles: "/blog", terms: "/blog/glossary" }, // where internal links to texts point
126
+ paths: { terms: "/glossary" }, // only to override the routes: articles default to routes.index, terms to routes.glossary
118
127
  ownOrigins: ["https://www.example.com"], // absolute links that count as internal, besides appOrigin and seo's origin
119
128
  appDir: "src/app", // routes for internal links; default src/app, else app
120
129
  privateRouteSegments: ["api", "(app)"], // route folders that are no link target; default ["api"]
@@ -274,13 +283,65 @@ decision for 60 s (`ttlMs`) and passes a request on when the database fails. Imp
274
283
  ui's: `@import "@softure-ai/blog/styles.css";`. A data change shows after `revalidateSeconds`, or at
275
284
  once with `revalidateTag(BLOG_CACHE_TAG, { expire: 0 })` (the refresh route above, for the command). Custom OG fonts: an own `opengraph-image.tsx` calling `renderArticleOgImage({ title, label, brand, fonts })`.
276
285
 
277
- The quality gate resolves internal links through `quality.paths` (default `/blog` and
278
- `/blog/glossary`, the default routes); an app that moves `routes` sets `quality.paths` to match.
286
+ The quality gate resolves internal links under the blog's `routes` (`index` for articles, `glossary`
287
+ for terms), so moving a route moves the check with it; `quality.paths` only overrides them.
288
+
289
+ The 410 page carries the module's copy (`gone.*`) and one link to the listing. `gonePage.links` adds
290
+ further ways on (a path from the site root or an https URL, a label per locale, at most five), and
291
+ `gonePage.render` writes the whole body with the app's own HTML and styles; the proxy still answers 410
292
+ with `text/html`:
293
+
294
+ ```ts
295
+ blog({
296
+ gonePage: {
297
+ links: [{ href: "/calculator", label: { en: "Try the calculator", pl: pl.blog.goneCalculator } }],
298
+ // or the whole page: (input) => html, with input.copy, input.lang, input.indexPath, input.links
299
+ render: ({ copy, lang, indexPath, links }) => renderMyGonePage({ copy, lang, indexPath, links }),
300
+ },
301
+ });
302
+ ```
303
+
304
+ **Own page components.** An app whose blog keeps its own look mounts its own pages and keeps the rest.
305
+ `generate*Metadata`, `generateBlogStaticParams` and `BlogArticleOgImage` mount next to its own page as
306
+ above. For a text the app already read (`getTextBySlug`), `/next` builds the same metadata and JSON-LD
307
+ the ready-made pages use, with no database read, so the app can extend them:
308
+
309
+ ```tsx
310
+ // app/blog/[slug]/page.tsx with the app's own view
311
+ import { buildArticleJsonLd, buildArticleMetadata, getTextBySlug } from "@softure-ai/blog/next";
312
+ import { getSoftureConfig } from "@softure-ai/core/next";
313
+ import { notFound } from "next/navigation";
314
+
315
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
316
+ const config = getSoftureConfig();
317
+ const article = await getTextBySlug(config, (await params).slug);
318
+ return article?.status === "published" && article.kind === "article" ? buildArticleMetadata(config, article) : {};
319
+ }
320
+
321
+ export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
322
+ const config = getSoftureConfig();
323
+ const article = await getTextBySlug(config, (await params).slug);
324
+ if (article?.status !== "published" || article.kind !== "article") notFound();
325
+ return (
326
+ <MyArticleView article={article}>
327
+ <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: buildArticleJsonLd(config, article) }} />
328
+ </MyArticleView>
329
+ );
330
+ }
331
+ ```
332
+
333
+ The builders: `buildBlogIndexMetadata(config, { isEmpty })`, `buildArticleMetadata(config, article)`,
334
+ `buildGlossaryIndexMetadata(config, { isEmpty })`, `buildTermMetadata(config, term)`,
335
+ `buildMethodMetadata(config)`, `buildArticleJsonLd(config, article)`, `buildTermJsonLd(config, term)` and
336
+ `buildGlossaryJsonLd(config, terms)` (`null` without terms); the JSON-LD comes serialized, safe inside a
337
+ `<script>`. `getCrumbLabels(config)` gives the breadcrumb names for `getArticleCrumbs`/`getTermCrumbs`
338
+ (`/server`); `getPageContext`, `renderPageBody` and `getRelatedArticles` render the rest.
279
339
 
280
340
  The commands:
281
341
 
282
342
  ```bash
283
- softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>] [--config <file>]
343
+ softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>]
344
+ [--stdin [--name <slug>.md]] [--history <file.json>] [--format text|lines] [--config <file>]
284
345
  softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>] [--config <file>]
285
346
  softure-blog skill install [--dir <path>] [--command <cmd>] [--check] [--config <file>]
286
347
  ```
@@ -300,6 +361,13 @@ softure-blog skill install [--dir <path>] [--command <cmd>] [--check] [--config
300
361
  listing or the glossary of its kind) as canonical URLs on seo's origin; a dry run prints them;
301
362
  `--no-indexnow` skips the submit (e.g. a local or CI database). A failed submit is a warning: the
302
363
  publish stays written and the exit code stays 0.
364
+ - `--stdin` reads the files from standard input instead of paths: with `--name <slug>.md`, the one file's
365
+ text; without it, a JSON bundle `{"files":[{"name":"<slug>.md","text":"…"}]}` (a whole folder, and
366
+ optionally `"history"`, the content of a `--history` file, so a container needs no file for it). A
367
+ release that publishes inside a container through an ssh gateway pipes the content in, so the image
368
+ needs no copy of `content/`. `--stdin` takes no paths; `--withdraw` with it needs `--name`.
369
+ - `--history <file.json>` imports the earlier life of an existing blog (see "Moving an existing blog in").
370
+ - `--format lines` prints the line contract below instead of the text for people.
303
371
  - Exit codes: 0 done, 1 refused or failed (nothing written), 2 usage error.
304
372
 
305
373
  Output, one line per text, then a summary:
@@ -312,6 +380,73 @@ summary: added 1, changed 1, unchanged 12
312
380
  dry run: nothing written; pass --commit to write
313
381
  ```
314
382
 
383
+ **The line contract** (`--format lines`), for a release script that greps the output. Every line goes to
384
+ standard output and starts `blog|`; fields are separated by `|`, and a `|` or a line break inside a value
385
+ becomes a space. Keys are stable: a new one may be added, an existing one never changes meaning. A run
386
+ prints exactly one outcome line: `blog|written`, `blog|dry-run`, `blog|refused` or `blog|failed|<message>`.
387
+
388
+ ```text
389
+ blog|warning|<subject>|<message>
390
+ blog|error|<subject>|<message>
391
+ blog|change|<added|changed|unchanged>|<id>|<status/slug before, or none>|<status/slug after>
392
+ blog|moved|<id>|<old slug>|<new slug>
393
+ blog|imported|<id>|<published_at ISO, or none>|<old slugs>
394
+ blog|summary|<added>|<changed>|<unchanged>
395
+ blog|written
396
+ blog|cache|off|<revalidateSeconds> | skipped | dry-run|<url> | refreshed|<url> | failed|<code>|<reason>
397
+ blog|indexnow|off|<reason> | skipped | dry-run|<count>|<urls> | submitted|<count>|<status>|<paths> | failed|<code>|<reason>|<paths>
398
+ ```
399
+
400
+ ```bash
401
+ # A release step: the content goes in on stdin, the contract comes back.
402
+ node -e 'const fs = require("fs"); const dir = "content/blog";
403
+ const files = fs.readdirSync(dir).filter((n) => n.endsWith(".md") && n !== "README.md").sort()
404
+ .map((name) => ({ name, text: fs.readFileSync(`${dir}/${name}`, "utf8") }));
405
+ process.stdout.write(JSON.stringify({ files }))' > bundle.json
406
+ OUT="$(ssh deploy@host 'docker compose exec -T app node blog.cjs publish --stdin --commit --format lines' < bundle.json)"
407
+ grep -q '^blog|written$' <<< "$OUT" || { grep '^blog|\(error\|failed\)' <<< "$OUT"; exit 1; }
408
+ ```
409
+
410
+ **Moving an existing blog in.** An app that already published its texts from its own tables keeps their
411
+ `published_at` (often only in its database, not in the files), their `updated_at` and their old slugs
412
+ with `--history <file.json>`. On the first publish of each article (no row in `blog.articles` yet), the
413
+ article takes `published_at` from the history unless its file sets one, `updated_at` from the history,
414
+ and its old slugs enter `blog.slug_history` (301s keep working); the run prints `imported` for it. An
415
+ article that already has a row ignores its entry, so the same file can stay in a release script; an
416
+ entry for an article outside the run is a warning. An old slug another article holds refuses the run.
417
+
418
+ ```json
419
+ {
420
+ "articles": [
421
+ {
422
+ "id": "index-funds",
423
+ "published_at": "2026-03-01T08:00:00+01:00",
424
+ "updated_at": "2026-06-15T10:30:00Z",
425
+ "old_slugs": [{ "slug": "what-is-an-index-fund", "changed_at": "2026-04-01T00:00:00Z" }]
426
+ },
427
+ { "id": "draft-text", "published_at": null }
428
+ ]
429
+ }
430
+ ```
431
+
432
+ `published_at` is required (`null` for a text never published), `updated_at` needs it, ids and slugs are
433
+ kebab-case, each article and old slug appears once. Timestamps reach Postgres as the text given, so a
434
+ `timestamptz` copied to the microsecond (`…:12.421579Z`) is stored to the microsecond and an import can be
435
+ checked by equality in SQL; Postgres rounds digits past the sixth. One query over
436
+ an app's own tables (here `blog_articles(id, published_at, updated_at)` and
437
+ `blog_slug_history(old_slug, article_id, changed_at)`) writes the file:
438
+
439
+ ```sql
440
+ \copy (SELECT json_build_object('articles', coalesce(json_agg(json_build_object(
441
+ 'id', a.id, 'published_at', a.published_at, 'updated_at', a.updated_at,
442
+ 'old_slugs', coalesce((SELECT json_agg(json_build_object('slug', h.old_slug, 'changed_at', h.changed_at))
443
+ FROM blog_slug_history h WHERE h.article_id = a.id), '[]'::json))), '[]'::json))
444
+ FROM blog_articles a) TO 'history.json'
445
+ ```
446
+
447
+ `publishArticle(ctx, input, { history })` and `runBlogPublish(ctx, files, { history })` (with
448
+ `parseArticleHistory(json)`) are the same import without the command line.
449
+
315
450
  Like `softure migrate`, the bin loads `softure.config.(ts|mts|js|mjs)` with Node and opens
316
451
  `database.handle` when the config sets one, otherwise `database.url`. When Node cannot load the config (path aliases, a bundled container), call
317
452
  `runBlogCli` from an app script:
@@ -432,7 +567,9 @@ const body = renderArticle(article.bodyMarkdown, {
432
567
  `%20`). A throwing `dimensions` fails the render (a bug); the gate reports it as `image-dimensions`.
433
568
  `checkArticleImage` and `findArticleImages` give the same verdict and the images of a text.
434
569
  - **External links** (`http(s)` or `//` to a host outside `siteHosts`) get `rel="noopener noreferrer"`,
435
- `target="_blank"`, a `↗` marker hidden from screen readers and a visually hidden "(opens in a new tab)".
570
+ `target="_blank"`, the class `blog-external`, a `↗` marker hidden from screen readers and a visually
571
+ hidden "(opens in a new tab)". `externalMarker: "text"` keeps only the hidden words, `"none"` neither
572
+ (the pages: `blog({ externalLinkMarker })`).
436
573
  - **Headings** get ids from their text (letters folded to ASCII, `-2` for a repeat, `section` without
437
574
  letters); `toc` renders `<nav class="blog-toc">` with nested lists.
438
575
  - **Glossary:** the first mention of each term form links to its definition; never inside headings,
@@ -459,9 +596,56 @@ const chartBlock: BlockPlugin = {
459
596
 
460
597
  Plugin output is the app's own code and is trusted as is: escape what goes into its HTML. A plugin
461
598
  that throws fails the render (a bug, not content). Without the plugin the same fence renders as a code
462
- block. `findArticleBlocks(markdown, plugins)` lists the blocks a text uses with their line and
463
- `requires`, without rendering. When any block returns a node, `html` is `null`: render `segments` in
464
- order (`html` segments as HTML, `node` segments as they are).
599
+ block. `findArticleBlocks(markdown, plugins)` lists the blocks a text uses with their line, syntax,
600
+ attributes and `requires`, without rendering. When any block returns a node, `html` is `null`: render
601
+ `segments` in order (`html` segments as HTML, `node` segments as they are).
602
+
603
+ **Directive plugins.** A plugin with `syntax: "directive"` renders a top-level line `::name{…}` instead
604
+ of a fence, the way many Markdown blogs embed a chart or a tool:
605
+
606
+ ```md
607
+ Paragraph before.
608
+ ::chart{type="wealth" scenario="w=35&d=300000" title="Your wealth"}
609
+ ```
610
+
611
+ ```ts
612
+ const chartDirective: BlockPlugin = {
613
+ type: "chart",
614
+ syntax: "directive",
615
+ requires: ["current_as_of"],
616
+ render: ({ attributes, article }) =>
617
+ attributes === null
618
+ ? { kind: "html", html: '<figure class="chart-error">…</figure>' }
619
+ : { kind: "html", html: renderChart(attributes, article) },
620
+ markdown: ({ attributes }) => chartAsTable(attributes), // optional, for Accept: text/markdown
621
+ };
622
+ ```
623
+
624
+ - The line stands alone (it may follow a paragraph line directly); the braces are optional; values are
625
+ double-quoted and hold no `"`; each key appears once. `attributes` holds them, `info` the raw text
626
+ inside the braces, `content` the whole line. Attributes that cannot be read reach the plugin as `null`
627
+ (show an error frame) and the gate refuses them.
628
+ - Only top-level lines count: a directive in a list, a quote, a fence or indented code stays text, and
629
+ so does a name no directive plugin registers. One type may have a fence plugin and a directive plugin.
630
+ - Register the same plugins in `quality.blocks`: the gate reports a directive's missing `requires`, and
631
+ with any directive plugin registered `block-directive` reports a `::name` line no plugin renders (a
632
+ typo would show as a paragraph) or one whose attributes cannot be read.
633
+ - `parseDirectiveLine` and `parseDirectiveAttributes` (`@softure-ai/blog/server`) are the parser.
634
+
635
+ **Markdown for agents.** `createBlogMarkdown(config)` (`@softure-ai/blog/proxy`) answers a GET or HEAD
636
+ of a published article or term whose `Accept` names `text/markdown` with at least the weight of
637
+ `text/html` (never `*/*`: browsers and crawlers keep the page) with `toArticleMarkdown(article)`: the
638
+ title, description, the day the facts were checked, the summary, the stored body, sources and FAQ.
639
+ Plugin blocks keep their source unless the plugin has `markdown`. The answer carries `Vary: Accept` and
640
+ `cache-control: private`, so a shared cache never hands Markdown to a browser. Put it before the redirects:
641
+
642
+ ```ts
643
+ const blogMarkdown = createBlogMarkdown(softureConfig);
644
+ const blogRedirects = createBlogRedirects(softureConfig);
645
+ export async function proxy(request: NextRequest) {
646
+ return (await blogMarkdown(request)) ?? (await blogRedirects(request)) ?? NextResponse.next();
647
+ }
648
+ ```
465
649
 
466
650
  ## 5. Migrations and tables
467
651
 
@@ -506,7 +690,7 @@ The OG card writes in `brand.fonts`, else in `next/og`'s default font:
506
690
  ```ts
507
691
  blog({
508
692
  brand: {
509
- name: "FIRE Tracker",
693
+ name: "Example",
510
694
  fonts: [
511
695
  // weight: 100…900 (default 400), style: "normal" | "italic" (default "normal")
512
696
  { name: "Inter", weight: 400, src: "assets/fonts/inter-latin-400-normal.woff" },
@@ -541,7 +725,7 @@ with `blog({ messages: { pl: { pages: { readMore: "..." } } } })`. Command outpu
541
725
 
542
726
  ## 10. Hooks
543
727
 
544
- - `blog({ fields })`: the app's frontmatter schema (FIRE_TRACKER's calculator scenario lives here).
728
+ - `blog({ fields })`: the app's frontmatter schema (e.g. a calculator scenario per article).
545
729
  - `gate` of `runBlogPublish` and `runBlogCli`: `(file, article) => problems`, called only for files
546
730
  going public; any problem refuses the whole run. Default in `runBlogCli`: `createQualityGate`.
547
731
  - `blog({ quality: { plugins } })`: the app's domain rules. A plugin declares its rules and checks one
@@ -567,8 +751,8 @@ export const tickerPlugin: QualityPlugin = {
567
751
  plugins in `quality.blocks` (type, info, fence line, `requires`), `today` and the language `ruleset` (for
568
752
  its number notation); the text helpers (`toProse`, `splitSentences`, `findSignificantNumbers`, …) are
569
753
  exported from `@softure-ai/blog/server`.
570
- - `renderArticle({ blocks })` and `blog({ blocks })`: block plugins for the app's fenced blocks
571
- (FIRE_TRACKER's engine chart).
754
+ - `renderArticle({ blocks })` and `blog({ blocks })`: block plugins for the app's fenced blocks and
755
+ `::directive` lines (an engine chart), with an optional Markdown form for agents.
572
756
  - The pages' `cta` and `afterArticle` slots: the app's call to action and blocks (a waitlist form).
573
757
 
574
758
  ## 11. GDPR
@@ -580,23 +764,24 @@ Articles hold editorial content, no personal data: nothing to export or delete.
580
764
  - Two runs that rename one article away from a slug and give it to another at the same moment can leave the
581
765
  slug both current and in the slug history (BF-12).
582
766
  - The content hash is part of the contract: a field added later enters it only when present.
583
- - No `--stdin` (a deploy transport).
584
767
  - The refresh route expires the cache of the instance that answers it. With several instances and Next's
585
768
  default (in-memory) cache handler, the others show a publish after `revalidateSeconds`; a shared cache
586
769
  handler covers them.
587
770
  - The renderer has no raw HTML and no figures: an image has no caption, and the app hosts and sizes its
588
771
  images itself (no `next/image`). A plugin fence inside a list or a quote stays a code
589
- block (a block node cannot sit inside a list's HTML).
772
+ block (a block node cannot sit inside a list's HTML), and a `::directive` there stays text; the gate
773
+ does not report a registered directive in such a place.
774
+ - Only leaf directives (`::name{…}`, one line): no container (`:::name … :::`) or inline (`:name[…]`) ones.
590
775
  - The gate reads Markdown line by line (blocks, not a syntax tree): enough for the rules, not a
591
776
  renderer. Fenced code and HTML comments are skipped.
592
- - **Adopting FIRE_TRACKER's gate:** `language: "pl"`, `ymyl: { ownCalculationMark }` with its calculation
593
- footnote's phrase, `voice.forbidFirstPersonSingular: true` plus its finance phrases, its domain as
594
- `ownOrigins`, `privateRouteSegments: ["api", "(app)"]`, and `rules-facts.ts` and `rules-chart.ts`
595
- as plugins (the package's tests hold stand-ins of both). Rule ids are English now (`kluczowy` →
596
- `crucial`, `myslniki` → `dashes`, …; the map is in the change archive), and the writing skill
597
- (`skill install`, replacing FIRE's `blog-pisz`) names them; FIRE's engine numbers, calculator scenario
598
- and chart block go into `blog({ skill: { sections } })`.
599
- - **Adopting from FIRE_TRACKER:** rename the frontmatter keys once (`typ` → `kind` with `artykul` →
777
+ - **Adopting an app's own Polish gate:** `language: "pl"`, `ymyl: { ownCalculationMark }` with its calculation
778
+ footnote's phrase, `voice.forbidFirstPersonSingular: true` plus its own phrases, its domain as
779
+ `ownOrigins`, `privateRouteSegments: ["api", "(app)"]`, and its own fact and chart rules as plugins
780
+ (the package's tests hold stand-ins of both). Rule ids are English (`kluczowy` → `crucial`,
781
+ `myslniki` → `dashes`, …; the map is in the change archive), and the writing skill (`skill install`,
782
+ replacing an app's own writing skill) names them; the app's own numbers, calculator scenario and chart
783
+ block go into `blog({ skill: { sections } })`.
784
+ - **Adopting Polish frontmatter keys:** rename the frontmatter keys once (`typ` → `kind` with `artykul` →
600
785
  `article` and `termin` → `term`, `formy` → `forms`, `klaster` → `cluster`, `filar` → `pillar`,
601
786
  `tytul` → `title`, `opis` → `description`, `w_skrocie` → `summary`, `aktualne_na` →
602
787
  `current_as_of`, `opublikowano` → `published_at`, `zrodla` → `sources` with `nazwa` → `name`,
@@ -0,0 +1,17 @@
1
+ import type { BlogRefreshOutcome } from "../discovery/refresh.js";
2
+ import type { BlogIndexNowSubmit } from "../discovery/submit.js";
3
+ import type { BlogPublishRun } from "../db/publish-run.js";
4
+ export interface CliOutput {
5
+ readonly log: (line: string) => void;
6
+ readonly error: (line: string) => void;
7
+ }
8
+ export type PublishFormat = "text" | "lines";
9
+ export interface PublishReporter {
10
+ /** A problem before or outside the run (a file it cannot read, the database); the run's outcome. */
11
+ readonly failed: (message: string) => void;
12
+ readonly run: (run: BlogPublishRun) => void;
13
+ readonly refresh: (outcome: BlogRefreshOutcome) => void;
14
+ readonly indexNow: (submit: BlogIndexNowSubmit) => void;
15
+ }
16
+ export declare function createPublishReporter(format: PublishFormat, output: CliOutput): PublishReporter;
17
+ //# sourceMappingURL=report.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../../src/cli/report.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAElE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,wBAAwB,CAAC;AACjE,OAAO,KAAK,EAAE,cAAc,EAAmC,MAAM,sBAAsB,CAAC;AAE5F,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CACxC;AAED,MAAM,MAAM,aAAa,GAAG,MAAM,GAAG,OAAO,CAAC;AAE7C,MAAM,WAAW,eAAe;IAC9B,oGAAoG;IACpG,QAAQ,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC3C,QAAQ,CAAC,GAAG,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IAC5C,QAAQ,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,kBAAkB,KAAK,IAAI,CAAC;IACxD,QAAQ,CAAC,QAAQ,EAAE,CAAC,MAAM,EAAE,kBAAkB,KAAK,IAAI,CAAC;CACzD;AAED,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,aAAa,EAAE,MAAM,EAAE,SAAS,GAAG,eAAe,CAE/F"}
@@ -0,0 +1,149 @@
1
+ import { BLOG_REFRESH_SECRET_ENV } from "../discovery/refresh.js";
2
+ export function createPublishReporter(format, output) {
3
+ return format === "lines" ? createLineReporter(output) : createTextReporter(output);
4
+ }
5
+ function countActions(run) {
6
+ const count = (action) => run.changes.filter((change) => change.action === action).length;
7
+ return { added: count("added"), changed: count("changed"), unchanged: count("unchanged") };
8
+ }
9
+ function formatBefore(change) {
10
+ return change.statusBefore === null || change.slugBefore === null ? "none" : `${change.statusBefore}/${change.slugBefore}`;
11
+ }
12
+ function formatDate(date) {
13
+ return date === null ? "none" : date.toISOString();
14
+ }
15
+ function createTextReporter(output) {
16
+ const formatProblem = (problem) => `${problem.subject}: ${problem.message}`;
17
+ return {
18
+ failed: (message) => output.error(`softure-blog publish: ${message}`),
19
+ run: (run) => {
20
+ for (const warning of run.warnings)
21
+ output.error(`warning ${formatProblem(warning)}`);
22
+ if (run.status === "refused") {
23
+ for (const problem of run.problems)
24
+ output.error(`error ${formatProblem(problem)}`);
25
+ output.error("refused: nothing written; fix the problems above");
26
+ return;
27
+ }
28
+ for (const change of run.changes) {
29
+ output.log(`${change.action} ${change.id} ${formatBefore(change)} -> ${change.statusAfter}/${change.slug}`);
30
+ if (change.previousSlug !== null)
31
+ output.log(`moved ${change.id} ${change.previousSlug} -> ${change.slug}`);
32
+ if (change.imported !== undefined) {
33
+ output.log(`imported ${change.id} published_at=${formatDate(change.imported.publishedAt)} old_slugs=${String(change.imported.oldSlugs)}`);
34
+ }
35
+ }
36
+ const counts = countActions(run);
37
+ output.log(`summary: added ${String(counts.added)}, changed ${String(counts.changed)}, unchanged ${String(counts.unchanged)}`);
38
+ output.log(run.committed ? "written" : "dry run: nothing written; pass --commit to write");
39
+ },
40
+ refresh: (outcome) => {
41
+ switch (outcome.kind) {
42
+ case "not_configured":
43
+ output.log(`cache: the running app shows the change within revalidateSeconds (${String(outcome.revalidateSeconds)} s); set ${BLOG_REFRESH_SECRET_ENV} to refresh it now`);
44
+ return;
45
+ case "skipped":
46
+ output.log("cache: no text changed, nothing to refresh");
47
+ return;
48
+ case "dry_run":
49
+ output.log(`cache: dry run, a commit would refresh ${outcome.url}`);
50
+ return;
51
+ case "refreshed":
52
+ output.log(`cache: refreshed ${outcome.url}`);
53
+ return;
54
+ case "failed":
55
+ output.error(`warning cache: ${outcome.reason} (${outcome.code}); the publish is written, the app shows it within ${String(outcome.revalidateSeconds)} s`);
56
+ return;
57
+ }
58
+ },
59
+ indexNow: ({ paths, outcome }) => {
60
+ switch (outcome.kind) {
61
+ case "not_configured":
62
+ output.log(`indexnow: off, ${outcome.reason}`);
63
+ return;
64
+ case "skipped":
65
+ output.log("indexnow: no public address changed, nothing to submit");
66
+ return;
67
+ case "dry_run":
68
+ output.log(`indexnow: dry run, a commit would submit ${String(outcome.urls.length)} URL(s): ${outcome.urls.join(" ")}`);
69
+ return;
70
+ case "submitted":
71
+ output.log(`indexnow: submitted ${String(outcome.count)} URL(s) (${String(outcome.status)}): ${paths.join(" ")}`);
72
+ return;
73
+ case "failed":
74
+ output.error(`warning indexnow: ${outcome.reason} (${outcome.code}); the publish is written, submit the addresses later: ${paths.join(" ")}`);
75
+ return;
76
+ }
77
+ },
78
+ };
79
+ }
80
+ /** A value inside a contract line: no field separator, no line break. */
81
+ function toField(value) {
82
+ return String(value).replace(/[|\r\n]+/g, " ");
83
+ }
84
+ function createLineReporter(output) {
85
+ // Every contract line goes to standard output, so `2>&1` is not needed to read them.
86
+ const line = (...fields) => output.log(["blog", ...fields.map(toField)].join("|"));
87
+ return {
88
+ failed: (message) => line("failed", message),
89
+ run: (run) => {
90
+ for (const warning of run.warnings)
91
+ line("warning", warning.subject, warning.message);
92
+ if (run.status === "refused") {
93
+ for (const problem of run.problems)
94
+ line("error", problem.subject, problem.message);
95
+ line("refused");
96
+ return;
97
+ }
98
+ for (const change of run.changes) {
99
+ line("change", change.action, change.id, formatBefore(change), `${change.statusAfter}/${change.slug}`);
100
+ if (change.previousSlug !== null)
101
+ line("moved", change.id, change.previousSlug, change.slug);
102
+ if (change.imported !== undefined)
103
+ line("imported", change.id, formatDate(change.imported.publishedAt), change.imported.oldSlugs);
104
+ }
105
+ const counts = countActions(run);
106
+ line("summary", counts.added, counts.changed, counts.unchanged);
107
+ line(run.committed ? "written" : "dry-run");
108
+ },
109
+ refresh: (outcome) => {
110
+ switch (outcome.kind) {
111
+ case "not_configured":
112
+ line("cache", "off", outcome.revalidateSeconds);
113
+ return;
114
+ case "skipped":
115
+ line("cache", "skipped");
116
+ return;
117
+ case "dry_run":
118
+ line("cache", "dry-run", outcome.url);
119
+ return;
120
+ case "refreshed":
121
+ line("cache", "refreshed", outcome.url);
122
+ return;
123
+ case "failed":
124
+ line("cache", "failed", outcome.code, outcome.reason);
125
+ return;
126
+ }
127
+ },
128
+ indexNow: ({ paths, outcome }) => {
129
+ switch (outcome.kind) {
130
+ case "not_configured":
131
+ line("indexnow", "off", outcome.reason);
132
+ return;
133
+ case "skipped":
134
+ line("indexnow", "skipped");
135
+ return;
136
+ case "dry_run":
137
+ line("indexnow", "dry-run", outcome.urls.length, outcome.urls.join(" "));
138
+ return;
139
+ case "submitted":
140
+ line("indexnow", "submitted", outcome.count, outcome.status, paths.join(" "));
141
+ return;
142
+ case "failed":
143
+ line("indexnow", "failed", outcome.code, outcome.reason, paths.join(" "));
144
+ return;
145
+ }
146
+ },
147
+ };
148
+ }
149
+ //# sourceMappingURL=report.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"report.js","sourceRoot":"","sources":["../../src/cli/report.ts"],"names":[],"mappings":"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"}
package/dist/cli/run.d.ts CHANGED
@@ -2,10 +2,8 @@ import { type Clock, type SoftureConfig } from "@softure-ai/core";
2
2
  import { type DatabaseHandle } from "@softure-ai/db";
3
3
  import { type PublishGate } from "../db/publish-run.js";
4
4
  import type { FetchLike } from "../quality/external-links.js";
5
- export interface CliOutput {
6
- readonly log: (line: string) => void;
7
- readonly error: (line: string) => void;
8
- }
5
+ import { type CliOutput, type PublishFormat } from "./report.js";
6
+ export type { CliOutput };
9
7
  export interface RunBlogCliOptions {
10
8
  /** The app's config with `blog()` among its modules. */
11
9
  readonly config: SoftureConfig;
@@ -29,11 +27,13 @@ export interface RunBlogCliOptions {
29
27
  readonly refreshFetch?: typeof fetch;
30
28
  /** Where `publish` reads BLOG_REFRESH_SECRET. Default: `process.env`. */
31
29
  readonly env?: Readonly<Record<string, string | undefined>>;
30
+ /** What `publish --stdin` reads. Default: the whole of `process.stdin`, as UTF-8. */
31
+ readonly readStdin?: () => Promise<string>;
32
32
  }
33
33
  export declare const EXIT_OK = 0;
34
34
  export declare const EXIT_FAILED = 1;
35
35
  export declare const EXIT_USAGE = 2;
36
- export declare const BLOG_USAGE = "Usage:\n softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>]\n softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>]\n softure-blog skill install [--dir <path>] [--command <cmd>] [--check]\n\npublish Brings the blog's tables to the state of the article files. A <path> is a file or a\n folder (every *.md in it except README.md); without one, blog({ contentDir }).\n Every file is checked before the first write, and one problem writes nothing.\n --commit write the changes; without it, a dry run that shows them and writes nothing\n --withdraw publish the one given file as withdrawn, whatever its status\n --no-indexnow do not submit the changed addresses to IndexNow\n --app-url the running app's origin for the cache refresh; default: appOrigin\n Files going public pass the quality gate first; an error writes nothing.\n With BLOG_REFRESH_SECRET set, a commit that changed a text asks the running\n app (refreshBlogCache) to refresh its blog cache, before the IndexNow submit.\n With seo({ indexNow }) enabled, a commit submits the addresses whose answer\n changed (the text, its old slug, its listing) to IndexNow; a dry run prints them.\n\ncheck Runs the quality gate of blog({ quality }) over the files, without a database, and\n prints every finding as file:line: severity [rule] message. Exits 1 on any error.\n --external also request every external link (2xx after redirects)\n --today the date to check freshness against; default: today in the app's time zone\n\nskill install\n Writes the article writing skill, filled from blog({ quality, skill }),\n into .claude/skills/blog-write. Overwrites only a skill it generated before.\n --dir the skill folder; default .claude/skills/blog-write\n --command how the skill runs this command; default \"npx softure-blog\"\n --check write nothing; exit 1 when the folder differs from what install would write\n\nOptions:\n --config <file> the app's softure.config file (bin only)\n --help show this help";
36
+ export declare const BLOG_USAGE = "Usage:\n softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>]\n [--stdin [--name <slug>.md]] [--history <file.json>] [--format text|lines]\n softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>]\n softure-blog skill install [--dir <path>] [--command <cmd>] [--check]\n\npublish Brings the blog's tables to the state of the article files. A <path> is a file or a\n folder (every *.md in it except README.md); without one, blog({ contentDir }).\n Every file is checked before the first write, and one problem writes nothing.\n --commit write the changes; without it, a dry run that shows them and writes nothing\n --withdraw publish the one given file as withdrawn, whatever its status\n --no-indexnow do not submit the changed addresses to IndexNow\n --app-url the running app's origin for the cache refresh; default: appOrigin\n --stdin read the files from standard input instead of paths: one file named by\n --name, or without --name a JSON bundle {\"files\":[{\"name\",\"text\"}]}\n (optionally with \"history\", the content of a --history file)\n --history a JSON file of the articles' earlier dates and old slugs, applied on the\n first publish of each article (moving an existing blog in)\n --format text (default) or lines: a stable blog|<key>|... contract for scripts\n Files going public pass the quality gate first; an error writes nothing.\n With BLOG_REFRESH_SECRET set, a commit that changed a text asks the running\n app (refreshBlogCache) to refresh its blog cache, before the IndexNow submit.\n With seo({ indexNow }) enabled, a commit submits the addresses whose answer\n changed (the text, its old slug, its listing) to IndexNow; a dry run prints them.\n\ncheck Runs the quality gate of blog({ quality }) over the files, without a database, and\n prints every finding as file:line: severity [rule] message. Exits 1 on any error.\n --external also request every external link (2xx after redirects)\n --today the date to check freshness against; default: today in the app's time zone\n\nskill install\n Writes the article writing skill, filled from blog({ quality, skill }),\n into .claude/skills/blog-write. Overwrites only a skill it generated before.\n --dir the skill folder; default .claude/skills/blog-write\n --command how the skill runs this command; default \"npx softure-blog\"\n --check write nothing; exit 1 when the folder differs from what install would write\n\nOptions:\n --config <file> the app's softure.config file (bin only)\n --help show this help";
37
37
  export type BlogCommand = {
38
38
  readonly kind: "help";
39
39
  } | {
@@ -44,6 +44,13 @@ export type BlogCommand = {
44
44
  readonly indexNow: boolean;
45
45
  /** The origin `--app-url` gives; `null`: `appOrigin`. */
46
46
  readonly appUrl: string | null;
47
+ /** Read the files from standard input; `name`: one file, `null`: a JSON bundle. */
48
+ readonly stdin: {
49
+ readonly name: string | null;
50
+ } | null;
51
+ /** The `--history` file, as given. */
52
+ readonly history: string | null;
53
+ readonly format: PublishFormat;
47
54
  } | {
48
55
  readonly kind: "check";
49
56
  readonly paths: readonly string[];