@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
@@ -50,7 +50,7 @@ export function getQualitySettings(config) {
50
50
  const { quality, images } = getBlogOptions(config);
51
51
  if (quality === false)
52
52
  return null;
53
- const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone };
53
+ const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone, routes: getBlogRoutes(config) };
54
54
  return resolveQualitySettings(quality, site, images ?? null);
55
55
  }
56
56
  //# sourceMappingURL=options.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/server/options.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,OAAO,EAAE,SAAS,EAAE,WAAW,EAA6C,MAAM,kBAAkB,CAAC;AAGrG,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAmB,MAAM,mBAAmB,CAAC;AACtF,OAAO,EAAE,sBAAsB,EAAwB,MAAM,wBAAwB,CAAC;AAEtF,MAAM,SAAS,GAAG,MAAM,CAAC;AAEzB,sGAAsG;AACtG,SAAS,aAAa,CAAC,MAAqB;IAC1C,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC5C,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,yFAAyF,CAAC,CAAC;IAC7G,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAqB;IAClD,kEAAkE;IAClE,OAAO,aAAa,CAAC,MAAM,CAAC,CAAC,OAAsB,CAAC;AACtD,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,eAAe,CAAC,MAAqB;IACnD,+EAA+E;IAC/E,OAAO,aAAa,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAiB,CAAC;AACvE,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,aAAa,CAAC,MAAqB;IACjD,MAAM,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC;IAC5C,MAAM,IAAI,GAAG,CAAC,IAAsB,EAAU,EAAE;QAC9C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;QAC1B,wFAAwF;QACxF,IAAI,IAAI,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,uCAAuC,CAAC,CAAC;QACjH,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC;IAC9B,CAAC,CAAC;IACF,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;AACxG,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,kBAAkB,CAAC,MAAqB;IACtD,MAAM,IAAI,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;IAClD,sFAAsF;IACtF,IAAI,IAAI,KAAK,SAAS;QAAE,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;IACjH,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC;AAC9B,CAAC;AAED,uGAAuG;AACvG,MAAM,UAAU,oBAAoB,CAAC,MAAqB;IACxD,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACvC,OAAO,gBAAgB,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;AAC1D,CAAC;AAED,kGAAkG;AAClG,MAAM,UAAU,kBAAkB,CAAC,MAAqB;IACtD,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACnD,IAAI,OAAO,KAAK,KAAK;QAAE,OAAO,IAAI,CAAC;IACnC,MAAM,IAAI,GAAG,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,CAAC;IAChH,OAAO,sBAAsB,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,IAAI,CAAC,CAAC;AAC/D,CAAC"}
1
+ {"version":3,"file":"options.js","sourceRoot":"","sources":["../../src/server/options.ts"],"names":[],"mappings":"AAAA,qFAAqF;AACrF,OAAO,EAAE,SAAS,EAAE,WAAW,EAA6C,MAAM,kBAAkB,CAAC;AAGrG,OAAO,EAAE,gBAAgB,EAAE,cAAc,EAAmB,MAAM,mBAAmB,CAAC;AACtF,OAAO,EAAE,sBAAsB,EAAwB,MAAM,wBAAwB,CAAC;AAEtF,MAAM,SAAS,GAAG,MAAM,CAAC;AAEzB,sGAAsG;AACtG,SAAS,aAAa,CAAC,MAAqB;IAC1C,MAAM,MAAM,GAAG,SAAS,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;IAC5C,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,MAAM,IAAI,KAAK,CAAC,yFAAyF,CAAC,CAAC;IAC7G,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAED,MAAM,UAAU,cAAc,CAAC,MAAqB;IAClD,kEAAkE;IAClE,OAAO,aAAa,CAAC,MAAM,CAAC,CAAC,OAAsB,CAAC;AACtD,CAAC;AAED,+EAA+E;AAC/E,MAAM,UAAU,eAAe,CAAC,MAAqB;IACnD,+EAA+E;IAC/E,OAAO,aAAa,CAAC,MAAM,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,MAAM,CAAiB,CAAC;AACvE,CAAC;AAED,sFAAsF;AACtF,MAAM,UAAU,aAAa,CAAC,MAAqB;IACjD,MAAM,MAAM,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC;IAC5C,MAAM,IAAI,GAAG,CAAC,IAAsB,EAAU,EAAE;QAC9C,MAAM,IAAI,GAAG,MAAM,CAAC,IAAI,CAAC,CAAC;QAC1B,wFAAwF;QACxF,IAAI,IAAI,KAAK,SAAS;YAAE,MAAM,IAAI,KAAK,CAAC,4BAA4B,IAAI,uCAAuC,CAAC,CAAC;QACjH,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC;IAC9B,CAAC,CAAC;IACF,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,OAAO,CAAC,EAAE,QAAQ,EAAE,IAAI,CAAC,UAAU,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,QAAQ,CAAC,EAAE,GAAG,EAAE,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;AACxG,CAAC;AAED,2FAA2F;AAC3F,MAAM,UAAU,kBAAkB,CAAC,MAAqB;IACtD,MAAM,IAAI,GAAG,aAAa,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC;IAClD,sFAAsF;IACtF,IAAI,IAAI,KAAK,SAAS;QAAE,MAAM,IAAI,KAAK,CAAC,uEAAuE,CAAC,CAAC;IACjH,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC;AAC9B,CAAC;AAED,uGAAuG;AACvG,MAAM,UAAU,oBAAoB,CAAC,MAAqB;IACxD,MAAM,OAAO,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACvC,OAAO,gBAAgB,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,CAAC;AAC1D,CAAC;AAED,kGAAkG;AAClG,MAAM,UAAU,kBAAkB,CAAC,MAAqB;IACtD,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,GAAG,cAAc,CAAC,MAAM,CAAC,CAAC;IACnD,IAAI,OAAO,KAAK,KAAK;QAAE,OAAO,IAAI,CAAC;IACnC,MAAM,IAAI,GAAG,EAAE,SAAS,EAAE,MAAM,CAAC,SAAS,EAAE,UAAU,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC,MAAM,EAAE,QAAQ,EAAE,MAAM,CAAC,QAAQ,EAAE,MAAM,EAAE,aAAa,CAAC,MAAM,CAAC,EAAE,CAAC;IAC/I,OAAO,sBAAsB,CAAC,OAAO,EAAE,IAAI,EAAE,MAAM,IAAI,IAAI,CAAC,CAAC;AAC/D,CAAC"}
package/module.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "blog",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "dependsOn": { "seo": "^0.1.0?", "security": "^0.1.0?" },
5
5
  "dbSchema": "blog",
6
6
  "tables": ["articles", "slug_history"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@softure-ai/blog",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "SOFTURE AI blog module: articles and glossary terms kept as Markdown files with a strict YAML frontmatter (extendable by the app's own fields), stored in the module's Postgres schema by the softure-blog publish command (dry run by default, all or nothing, content hash, slug history, one pillar per cluster), a server-side Markdown renderer (no raw HTML, safe links, glossary links, block plugins), a text quality gate (softure-blog check, run before every publish) with language rulesets and rule plugins, the blog's pages ready to mount in Next.js (listing, article, glossary, method page, OG image, JSON-LD) with a proxy piece for 301 and 410, an article writing skill (softure-blog skill install) generated from the gate's rules, and discovery: an RSS feed, read next, sitemap entries and an IndexNow submit on publish through the optional @softure-ai/seo, with a rate-limited cache refresh route the publish calls first (through the optional @softure-ai/security).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,7 +31,8 @@ rewrite the paragraph around its one thought; do not swap a word for a synonym.
31
31
  | `length` | | | An article of {{articleWordsMin}} to {{articleWordsMax}} words, a term of {{termWordsMin}} to {{termWordsMax}}. |
32
32
  | `footnote-undefined` | | | Define every `[^id]` at the end of the file. |
33
33
  | `footnote-unused` | | | Remove a footnote definition nothing refers to, or refer to it. |
34
- | `block-requires` | | | A fenced block of the app's block plugins needs its frontmatter keys; add them. |
34
+ | `block-requires` | | | A block of the app's block plugins (a fence or a `::directive`) needs its frontmatter keys; add them. |
35
+ | `block-directive` | | | A `::directive` line names a directive the blog renders and writes its attributes as `key="value"` pairs, each key once. |
35
36
 
36
37
  ## Links
37
38
 
@@ -0,0 +1,182 @@
1
+ // How `softure-blog publish` prints its result: for a person (the default) or as a line contract a
2
+ // release script parses (`--format lines`). Both get the same events; only the wording differs.
3
+ //
4
+ // The line contract: every line starts `blog|`, fields are separated by `|`, and a `|` or a line break
5
+ // inside a value becomes a space. Keys are stable English words; a new key may be added, an existing
6
+ // one never changes meaning. A run prints exactly one outcome line: `blog|written`, `blog|dry-run`,
7
+ // `blog|refused` or `blog|failed|<message>`.
8
+ //
9
+ // blog|warning|<subject>|<message>
10
+ // blog|error|<subject>|<message> (before blog|refused)
11
+ // blog|change|<added|changed|unchanged>|<id>|<status/slug before or none>|<status/slug after>
12
+ // blog|moved|<id>|<old slug>|<new slug>
13
+ // blog|imported|<id>|<published_at ISO or none>|<old slugs>
14
+ // blog|summary|<added>|<changed>|<unchanged>
15
+ // blog|cache|<off|skipped|dry-run|refreshed|failed>|…
16
+ // blog|indexnow|<off|skipped|dry-run|submitted|failed>|…
17
+ import type { BlogRefreshOutcome } from "../discovery/refresh.js";
18
+ import { BLOG_REFRESH_SECRET_ENV } from "../discovery/refresh.js";
19
+ import type { BlogIndexNowSubmit } from "../discovery/submit.js";
20
+ import type { BlogPublishRun, PublishedChange, PublishProblem } from "../db/publish-run.js";
21
+
22
+ export interface CliOutput {
23
+ readonly log: (line: string) => void;
24
+ readonly error: (line: string) => void;
25
+ }
26
+
27
+ export type PublishFormat = "text" | "lines";
28
+
29
+ export interface PublishReporter {
30
+ /** A problem before or outside the run (a file it cannot read, the database); the run's outcome. */
31
+ readonly failed: (message: string) => void;
32
+ readonly run: (run: BlogPublishRun) => void;
33
+ readonly refresh: (outcome: BlogRefreshOutcome) => void;
34
+ readonly indexNow: (submit: BlogIndexNowSubmit) => void;
35
+ }
36
+
37
+ export function createPublishReporter(format: PublishFormat, output: CliOutput): PublishReporter {
38
+ return format === "lines" ? createLineReporter(output) : createTextReporter(output);
39
+ }
40
+
41
+ function countActions(run: Extract<BlogPublishRun, { status: "done" }>): Record<PublishedChange["action"], number> {
42
+ const count = (action: PublishedChange["action"]) => run.changes.filter((change) => change.action === action).length;
43
+ return { added: count("added"), changed: count("changed"), unchanged: count("unchanged") };
44
+ }
45
+
46
+ function formatBefore(change: PublishedChange): string {
47
+ return change.statusBefore === null || change.slugBefore === null ? "none" : `${change.statusBefore}/${change.slugBefore}`;
48
+ }
49
+
50
+ function formatDate(date: Date | null): string {
51
+ return date === null ? "none" : date.toISOString();
52
+ }
53
+
54
+ function createTextReporter(output: CliOutput): PublishReporter {
55
+ const formatProblem = (problem: PublishProblem) => `${problem.subject}: ${problem.message}`;
56
+ return {
57
+ failed: (message) => output.error(`softure-blog publish: ${message}`),
58
+ run: (run) => {
59
+ for (const warning of run.warnings) output.error(`warning ${formatProblem(warning)}`);
60
+ if (run.status === "refused") {
61
+ for (const problem of run.problems) output.error(`error ${formatProblem(problem)}`);
62
+ output.error("refused: nothing written; fix the problems above");
63
+ return;
64
+ }
65
+ for (const change of run.changes) {
66
+ output.log(`${change.action} ${change.id} ${formatBefore(change)} -> ${change.statusAfter}/${change.slug}`);
67
+ if (change.previousSlug !== null) output.log(`moved ${change.id} ${change.previousSlug} -> ${change.slug}`);
68
+ if (change.imported !== undefined) {
69
+ output.log(`imported ${change.id} published_at=${formatDate(change.imported.publishedAt)} old_slugs=${String(change.imported.oldSlugs)}`);
70
+ }
71
+ }
72
+ const counts = countActions(run);
73
+ output.log(`summary: added ${String(counts.added)}, changed ${String(counts.changed)}, unchanged ${String(counts.unchanged)}`);
74
+ output.log(run.committed ? "written" : "dry run: nothing written; pass --commit to write");
75
+ },
76
+ refresh: (outcome) => {
77
+ switch (outcome.kind) {
78
+ case "not_configured":
79
+ output.log(`cache: the running app shows the change within revalidateSeconds (${String(outcome.revalidateSeconds)} s); set ${BLOG_REFRESH_SECRET_ENV} to refresh it now`);
80
+ return;
81
+ case "skipped":
82
+ output.log("cache: no text changed, nothing to refresh");
83
+ return;
84
+ case "dry_run":
85
+ output.log(`cache: dry run, a commit would refresh ${outcome.url}`);
86
+ return;
87
+ case "refreshed":
88
+ output.log(`cache: refreshed ${outcome.url}`);
89
+ return;
90
+ case "failed":
91
+ output.error(`warning cache: ${outcome.reason} (${outcome.code}); the publish is written, the app shows it within ${String(outcome.revalidateSeconds)} s`);
92
+ return;
93
+ }
94
+ },
95
+ indexNow: ({ paths, outcome }) => {
96
+ switch (outcome.kind) {
97
+ case "not_configured":
98
+ output.log(`indexnow: off, ${outcome.reason}`);
99
+ return;
100
+ case "skipped":
101
+ output.log("indexnow: no public address changed, nothing to submit");
102
+ return;
103
+ case "dry_run":
104
+ output.log(`indexnow: dry run, a commit would submit ${String(outcome.urls.length)} URL(s): ${outcome.urls.join(" ")}`);
105
+ return;
106
+ case "submitted":
107
+ output.log(`indexnow: submitted ${String(outcome.count)} URL(s) (${String(outcome.status)}): ${paths.join(" ")}`);
108
+ return;
109
+ case "failed":
110
+ output.error(`warning indexnow: ${outcome.reason} (${outcome.code}); the publish is written, submit the addresses later: ${paths.join(" ")}`);
111
+ return;
112
+ }
113
+ },
114
+ };
115
+ }
116
+
117
+ /** A value inside a contract line: no field separator, no line break. */
118
+ function toField(value: string | number): string {
119
+ return String(value).replace(/[|\r\n]+/g, " ");
120
+ }
121
+
122
+ function createLineReporter(output: CliOutput): PublishReporter {
123
+ // Every contract line goes to standard output, so `2>&1` is not needed to read them.
124
+ const line = (...fields: readonly (string | number)[]) => output.log(["blog", ...fields.map(toField)].join("|"));
125
+ return {
126
+ failed: (message) => line("failed", message),
127
+ run: (run) => {
128
+ for (const warning of run.warnings) line("warning", warning.subject, warning.message);
129
+ if (run.status === "refused") {
130
+ for (const problem of run.problems) line("error", problem.subject, problem.message);
131
+ line("refused");
132
+ return;
133
+ }
134
+ for (const change of run.changes) {
135
+ line("change", change.action, change.id, formatBefore(change), `${change.statusAfter}/${change.slug}`);
136
+ if (change.previousSlug !== null) line("moved", change.id, change.previousSlug, change.slug);
137
+ if (change.imported !== undefined) line("imported", change.id, formatDate(change.imported.publishedAt), change.imported.oldSlugs);
138
+ }
139
+ const counts = countActions(run);
140
+ line("summary", counts.added, counts.changed, counts.unchanged);
141
+ line(run.committed ? "written" : "dry-run");
142
+ },
143
+ refresh: (outcome) => {
144
+ switch (outcome.kind) {
145
+ case "not_configured":
146
+ line("cache", "off", outcome.revalidateSeconds);
147
+ return;
148
+ case "skipped":
149
+ line("cache", "skipped");
150
+ return;
151
+ case "dry_run":
152
+ line("cache", "dry-run", outcome.url);
153
+ return;
154
+ case "refreshed":
155
+ line("cache", "refreshed", outcome.url);
156
+ return;
157
+ case "failed":
158
+ line("cache", "failed", outcome.code, outcome.reason);
159
+ return;
160
+ }
161
+ },
162
+ indexNow: ({ paths, outcome }) => {
163
+ switch (outcome.kind) {
164
+ case "not_configured":
165
+ line("indexnow", "off", outcome.reason);
166
+ return;
167
+ case "skipped":
168
+ line("indexnow", "skipped");
169
+ return;
170
+ case "dry_run":
171
+ line("indexnow", "dry-run", outcome.urls.length, outcome.urls.join(" "));
172
+ return;
173
+ case "submitted":
174
+ line("indexnow", "submitted", outcome.count, outcome.status, paths.join(" "));
175
+ return;
176
+ case "failed":
177
+ line("indexnow", "failed", outcome.code, outcome.reason, paths.join(" "));
178
+ return;
179
+ }
180
+ },
181
+ };
182
+ }
package/src/cli/run.ts CHANGED
@@ -11,9 +11,11 @@ import { basename, dirname, join, relative, resolve } from "node:path";
11
11
  import { parseArgs } from "node:util";
12
12
  import { systemClock, type Clock, type SoftureConfig, type SoftureDatabaseConfig } from "@softure-ai/core";
13
13
  import { openCommandDatabase, type CommandDatabase, type DatabaseHandle } from "@softure-ai/db";
14
- import { BLOG_REFRESH_SECRET_ENV, requestBlogRefresh, type BlogRefreshOutcome } from "../discovery/refresh.js";
15
- import { submitBlogChanges, type BlogIndexNowSubmit } from "../discovery/submit.js";
16
- import { runBlogPublish, type ArticleFile, type BlogPublishRun, type PublishedChange, type PublishGate, type PublishProblem } from "../db/publish-run.js";
14
+ import { z } from "zod";
15
+ import { BLOG_REFRESH_SECRET_ENV, requestBlogRefresh } from "../discovery/refresh.js";
16
+ import { submitBlogChanges } from "../discovery/submit.js";
17
+ import { parseArticleHistory, type ArticleHistoryMap } from "../db/history.js";
18
+ import { runBlogPublish, type ArticleFile, type PublishGate } from "../db/publish-run.js";
17
19
  import { checkArticleFiles, type FileCheckResult } from "../quality/check-files.js";
18
20
  import type { FetchLike } from "../quality/external-links.js";
19
21
  import { createQualityGate } from "../quality/gate.js";
@@ -22,12 +24,10 @@ import { getLocalDate } from "../quality/settings.js";
22
24
  import type { OgFontSource } from "../options.js";
23
25
  import { createOgFontLoader } from "../server/og-fonts.js";
24
26
  import { getBlogOptions, getBlogReservedSlugs, getQualitySettings } from "../server/options.js";
27
+ import { createPublishReporter, type CliOutput, type PublishFormat } from "./report.js";
25
28
  import { DEFAULT_SKILL_COMMAND, DEFAULT_SKILL_DIR, renderBlogSkill, SKILL_MARKER, type SkillFile } from "./skill.js";
26
29
 
27
- export interface CliOutput {
28
- readonly log: (line: string) => void;
29
- readonly error: (line: string) => void;
30
- }
30
+ export type { CliOutput };
31
31
 
32
32
  export interface RunBlogCliOptions {
33
33
  /** The app's config with `blog()` among its modules. */
@@ -52,6 +52,8 @@ export interface RunBlogCliOptions {
52
52
  readonly refreshFetch?: typeof fetch;
53
53
  /** Where `publish` reads BLOG_REFRESH_SECRET. Default: `process.env`. */
54
54
  readonly env?: Readonly<Record<string, string | undefined>>;
55
+ /** What `publish --stdin` reads. Default: the whole of `process.stdin`, as UTF-8. */
56
+ readonly readStdin?: () => Promise<string>;
55
57
  }
56
58
 
57
59
  export const EXIT_OK = 0;
@@ -60,6 +62,7 @@ export const EXIT_USAGE = 2;
60
62
 
61
63
  export const BLOG_USAGE = `Usage:
62
64
  softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>]
65
+ [--stdin [--name <slug>.md]] [--history <file.json>] [--format text|lines]
63
66
  softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>]
64
67
  softure-blog skill install [--dir <path>] [--command <cmd>] [--check]
65
68
 
@@ -70,6 +73,12 @@ publish Brings the blog's tables to the state of the article files. A <path> i
70
73
  --withdraw publish the one given file as withdrawn, whatever its status
71
74
  --no-indexnow do not submit the changed addresses to IndexNow
72
75
  --app-url the running app's origin for the cache refresh; default: appOrigin
76
+ --stdin read the files from standard input instead of paths: one file named by
77
+ --name, or without --name a JSON bundle {"files":[{"name","text"}]}
78
+ (optionally with "history", the content of a --history file)
79
+ --history a JSON file of the articles' earlier dates and old slugs, applied on the
80
+ first publish of each article (moving an existing blog in)
81
+ --format text (default) or lines: a stable blog|<key>|... contract for scripts
73
82
  Files going public pass the quality gate first; an error writes nothing.
74
83
  With ${BLOG_REFRESH_SECRET_ENV} set, a commit that changed a text asks the running
75
84
  app (refreshBlogCache) to refresh its blog cache, before the IndexNow submit.
@@ -107,6 +116,11 @@ export type BlogCommand =
107
116
  readonly indexNow: boolean;
108
117
  /** The origin `--app-url` gives; `null`: `appOrigin`. */
109
118
  readonly appUrl: string | null;
119
+ /** Read the files from standard input; `name`: one file, `null`: a JSON bundle. */
120
+ readonly stdin: { readonly name: string | null } | null;
121
+ /** The `--history` file, as given. */
122
+ readonly history: string | null;
123
+ readonly format: PublishFormat;
110
124
  }
111
125
  | { readonly kind: "check"; readonly paths: readonly string[]; readonly external: boolean; readonly today: string | null }
112
126
  | { readonly kind: "skill-install"; readonly dir: string; readonly command: string; readonly check: boolean };
@@ -146,10 +160,28 @@ export function parseBlogCommand(argv: readonly string[]): BlogCommand | string
146
160
  const { values, positionals } = parsed;
147
161
  if (values.help === true) return { kind: "help" };
148
162
  const withdraw = values.withdraw === true;
149
- if (withdraw && positionals.length !== 1) return "--withdraw takes exactly one article file";
163
+ const isStdin = values.stdin === true;
164
+ const stdinName = values.name ?? null;
165
+ if (isStdin && positionals.length > 0) return "--stdin reads the files from standard input; give no paths with it";
166
+ if (!isStdin && stdinName !== null) return "--name names the file --stdin reads; use it with --stdin";
167
+ if (stdinName !== null && !/^[^/\\]+\.md$/.test(stdinName)) return `--name needs a file name <slug>.md, not "${stdinName}"`;
168
+ if (withdraw && !(isStdin ? stdinName !== null : positionals.length === 1)) return "--withdraw takes exactly one article file";
150
169
  const appUrl = values["app-url"] === undefined ? null : parseOrigin(values["app-url"]);
151
170
  if (appUrl === undefined) return `--app-url needs an http or https origin, e.g. http://web:3000, not "${values["app-url"] ?? ""}"`;
152
- return { kind: "publish", paths: positionals, commit: values.commit === true, withdraw, indexNow: values["no-indexnow"] !== true, appUrl };
171
+ const format = values.format ?? "text";
172
+ if (format !== "text" && format !== "lines") return `--format is text or lines, not "${format}"`;
173
+ if (values.history !== undefined && values.history.trim() === "") return "--history needs a JSON file";
174
+ return {
175
+ kind: "publish",
176
+ paths: positionals,
177
+ commit: values.commit === true,
178
+ withdraw,
179
+ indexNow: values["no-indexnow"] !== true,
180
+ appUrl,
181
+ stdin: isStdin ? { name: stdinName } : null,
182
+ history: values.history ?? null,
183
+ format,
184
+ };
153
185
  }
154
186
 
155
187
  /** The origin of an http(s) URL, or `undefined` for anything else. */
@@ -232,6 +264,10 @@ function parsePublishArgs(args: readonly string[]) {
232
264
  withdraw: { type: "boolean" },
233
265
  "no-indexnow": { type: "boolean" },
234
266
  "app-url": { type: "string" },
267
+ stdin: { type: "boolean" },
268
+ name: { type: "string" },
269
+ history: { type: "string" },
270
+ format: { type: "string" },
235
271
  help: { type: "boolean" },
236
272
  },
237
273
  strict: true,
@@ -242,27 +278,28 @@ function parsePublishArgs(args: readonly string[]) {
242
278
  async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, options: RunBlogCliOptions, output: CliOutput): Promise<number> {
243
279
  const cwd = options.cwd ?? process.cwd();
244
280
  const { config } = options;
281
+ const report = createPublishReporter(command.format, output);
282
+ const fail = (message: string): number => {
283
+ report.failed(message);
284
+ return EXIT_FAILED;
285
+ };
245
286
  let blogOptions: ReturnType<typeof getBlogOptions>;
246
287
  try {
247
288
  blogOptions = getBlogOptions(config);
248
289
  } catch (error) {
249
- output.error(`softure-blog publish: ${describeError(error)}`);
250
- return EXIT_FAILED;
251
- }
252
- const paths = command.paths.length > 0 ? command.paths : [blogOptions.contentDir];
253
- const files = await readArticleFiles(paths.map((path) => resolve(cwd, path)));
254
- if (typeof files === "string") {
255
- output.error(`softure-blog publish: ${files}`);
256
- return EXIT_FAILED;
257
- }
258
- if (command.withdraw && files.length !== 1) {
259
- output.error("softure-blog publish: --withdraw takes exactly one article file, not a folder");
260
- return EXIT_FAILED;
261
- }
262
- if (config.database === null) {
263
- output.error("softure-blog publish: the config has no database; set database.url in softure.config");
264
- return EXIT_FAILED;
290
+ return fail(describeError(error));
265
291
  }
292
+ const input = command.stdin === null
293
+ ? { files: await readArticleFiles((command.paths.length > 0 ? command.paths : [blogOptions.contentDir]).map((path) => resolve(cwd, path))), history: undefined }
294
+ : await readStdinBundle(command.stdin.name, options.readStdin ?? readProcessStdin);
295
+ if (typeof input === "string") return fail(input);
296
+ const { files } = input;
297
+ if (typeof files === "string") return fail(files);
298
+ if (command.withdraw && files.length !== 1) return fail("--withdraw takes exactly one article file, not a folder");
299
+ if (command.history !== null && input.history !== undefined) return fail("the bundle on standard input carries a history; drop --history");
300
+ const history = command.history === null ? input.history : await readHistoryFile(resolve(cwd, command.history));
301
+ if (typeof history === "string") return fail(history);
302
+ if (config.database === null) return fail("the config has no database; set database.url in softure.config");
266
303
 
267
304
  const clock = options.clock ?? systemClock;
268
305
  let gate = options.gate;
@@ -275,8 +312,7 @@ async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, op
275
312
  try {
276
313
  opened = await openDatabase(config.database, options.openDatabase);
277
314
  } catch (error) {
278
- output.error(`softure-blog publish: ${describeError(error)}`);
279
- return EXIT_FAILED;
315
+ return fail(describeError(error));
280
316
  }
281
317
  try {
282
318
  const run = await runBlogPublish(
@@ -288,9 +324,10 @@ async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, op
288
324
  reservedSlugs: getBlogReservedSlugs(config),
289
325
  ...(blogOptions.fields === undefined ? {} : { fields: blogOptions.fields }),
290
326
  ...(gate === undefined ? {} : { gate }),
327
+ ...(history === undefined ? {} : { history }),
291
328
  },
292
329
  );
293
- const code = reportRun(run, output);
330
+ report.run(run);
294
331
  if (run.status === "done") {
295
332
  // Before the IndexNow submit: a crawler that answers the ping must find the new text.
296
333
  const refresh = await requestBlogRefresh(config, run.changes, {
@@ -299,16 +336,15 @@ async function runPublish(command: Extract<BlogCommand, { kind: "publish" }>, op
299
336
  ...(options.env === undefined ? {} : { env: options.env }),
300
337
  ...(options.refreshFetch === undefined ? {} : { fetchImpl: options.refreshFetch }),
301
338
  });
302
- reportRefresh(refresh, output);
339
+ report.refresh(refresh);
303
340
  }
304
341
  if (run.status === "done" && command.indexNow) {
305
- reportIndexNow(await submitBlogChanges(config, run.changes, { commit: run.committed, ...(options.indexNowFetch === undefined ? {} : { fetchImpl: options.indexNowFetch }) }), output);
342
+ report.indexNow(await submitBlogChanges(config, run.changes, { commit: run.committed, ...(options.indexNowFetch === undefined ? {} : { fetchImpl: options.indexNowFetch }) }));
306
343
  }
307
- return code;
344
+ return run.status === "done" ? EXIT_OK : EXIT_FAILED;
308
345
  } catch (error) {
309
346
  // Driver errors: the message only, never a stack or the database URL.
310
- output.error(`softure-blog publish: ${describeError(error)} (did softure migrate run?)`);
311
- return EXIT_FAILED;
347
+ return fail(`${describeError(error)} (did softure migrate run?)`);
312
348
  } finally {
313
349
  await opened.close();
314
350
  }
@@ -353,7 +389,7 @@ async function runCheck(command: Extract<BlogCommand, { kind: "check" }>, option
353
389
  const resolveInternalLink = createInternalLinkResolver({
354
390
  appDir: findAppDir(cwd, settings.options.appDir),
355
391
  privateRouteSegments: settings.options.privateRouteSegments,
356
- paths: settings.options.paths,
392
+ paths: settings.paths,
357
393
  content,
358
394
  });
359
395
  const results = await checkArticleFiles(files, {
@@ -450,72 +486,66 @@ function reportCheck(results: readonly FileCheckResult[], files: readonly ReadAr
450
486
  return errors > 0 ? EXIT_FAILED : EXIT_OK;
451
487
  }
452
488
 
453
- function reportRun(run: BlogPublishRun, output: CliOutput): number {
454
- for (const warning of run.warnings) output.error(`warning ${formatProblem(warning)}`);
455
- if (run.status === "refused") {
456
- for (const problem of run.problems) output.error(`error ${formatProblem(problem)}`);
457
- output.error("refused: nothing written; fix the problems above");
458
- return EXIT_FAILED;
459
- }
460
- for (const change of run.changes) {
461
- output.log(formatChange(change));
462
- if (change.previousSlug !== null) output.log(`moved ${change.id} ${change.previousSlug} -> ${change.slug}`);
463
- }
464
- const count = (action: PublishedChange["action"]) => String(run.changes.filter((change) => change.action === action).length);
465
- output.log(`summary: added ${count("added")}, changed ${count("changed")}, unchanged ${count("unchanged")}`);
466
- output.log(run.committed ? "written" : "dry run: nothing written; pass --commit to write");
467
- return EXIT_OK;
468
- }
469
489
 
470
- /** One line about the cache refresh; a failure is a warning and never changes the exit code. */
471
- function reportRefresh(outcome: BlogRefreshOutcome, output: CliOutput): void {
472
- switch (outcome.kind) {
473
- case "not_configured":
474
- output.log(`cache: the running app shows the change within revalidateSeconds (${String(outcome.revalidateSeconds)} s); set ${BLOG_REFRESH_SECRET_ENV} to refresh it now`);
475
- return;
476
- case "skipped":
477
- output.log("cache: no text changed, nothing to refresh");
478
- return;
479
- case "dry_run":
480
- output.log(`cache: dry run, a commit would refresh ${outcome.url}`);
481
- return;
482
- case "refreshed":
483
- output.log(`cache: refreshed ${outcome.url}`);
484
- return;
485
- case "failed":
486
- output.error(`warning cache: ${outcome.reason} (${outcome.code}); the publish is written, the app shows it within ${String(outcome.revalidateSeconds)} s`);
487
- return;
488
- }
490
+
491
+
492
+
493
+
494
+ const stdinBundleSchema = z.strictObject({
495
+ files: z
496
+ .array(z.strictObject({ name: z.string().regex(/^[^/\\]+\.md$/, "must be a file name <slug>.md"), text: z.string() }))
497
+ .min(1, "must list at least one file"),
498
+ /** The `--history` file's content, for a publish whose only input is standard input. */
499
+ history: z.unknown().optional(),
500
+ });
501
+
502
+ interface StdinBundle {
503
+ readonly files: ArticleFile[];
504
+ readonly history: ArticleHistoryMap | undefined;
489
505
  }
490
506
 
491
- /** One line about the IndexNow submit; a failure is a warning and never changes the exit code. */
492
- function reportIndexNow({ paths, outcome }: BlogIndexNowSubmit, output: CliOutput): void {
493
- switch (outcome.kind) {
494
- case "not_configured":
495
- output.log(`indexnow: off, ${outcome.reason}`);
496
- return;
497
- case "skipped":
498
- output.log("indexnow: no public address changed, nothing to submit");
499
- return;
500
- case "dry_run":
501
- output.log(`indexnow: dry run, a commit would submit ${String(outcome.urls.length)} URL(s): ${outcome.urls.join(" ")}`);
502
- return;
503
- case "submitted":
504
- output.log(`indexnow: submitted ${String(outcome.count)} URL(s) (${String(outcome.status)}): ${paths.join(" ")}`);
505
- return;
506
- case "failed":
507
- output.error(`warning indexnow: ${outcome.reason} (${outcome.code}); the publish is written, submit the addresses later: ${paths.join(" ")}`);
508
- return;
507
+ /** What `--stdin` gives: one file named by `--name`, or the JSON bundle (files, optional history); or why it cannot be read. */
508
+ async function readStdinBundle(name: string | null, readStdin: () => Promise<string>): Promise<StdinBundle | string> {
509
+ let text: string;
510
+ try {
511
+ text = await readStdin();
512
+ } catch (error) {
513
+ return `cannot read standard input: ${describeError(error)}`;
514
+ }
515
+ if (name !== null) return text.trim() === "" ? `standard input is empty; pipe the text of ${name}` : { files: [{ name, text }], history: undefined };
516
+ let json: unknown;
517
+ try {
518
+ json = JSON.parse(text);
519
+ } catch {
520
+ // The parser's message names a position in a text nobody sees; say what was expected instead.
521
+ return 'standard input is not a JSON bundle {"files":[{"name":"<slug>.md","text":"..."}]}; pass --name <slug>.md for one file';
509
522
  }
523
+ const parsed = stdinBundleSchema.safeParse(json);
524
+ if (!parsed.success) return `the bundle on standard input: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "bundle"}: ${issue.message}`).join("; ")}`;
525
+ if (parsed.data.history === undefined) return { files: parsed.data.files, history: undefined };
526
+ const history = parseArticleHistory(parsed.data.history);
527
+ if (!history.ok) return `the bundle on standard input: history.${history.errors.join("; history.")}`;
528
+ return { files: parsed.data.files, history: history.history };
510
529
  }
511
530
 
512
- function formatChange(change: PublishedChange): string {
513
- const before = change.statusBefore === null || change.slugBefore === null ? "none" : `${change.statusBefore}/${change.slugBefore}`;
514
- return `${change.action} ${change.id} ${before} -> ${change.statusAfter}/${change.slug}`;
531
+ async function readProcessStdin(): Promise<string> {
532
+ const chunks: Buffer[] = [];
533
+ for await (const chunk of process.stdin) chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : (chunk as Buffer));
534
+ return Buffer.concat(chunks).toString("utf8");
515
535
  }
516
536
 
517
- function formatProblem(problem: PublishProblem): string {
518
- return `${problem.subject}: ${problem.message}`;
537
+ /** The `--history` file, parsed; or why it cannot be used. */
538
+ async function readHistoryFile(path: string): Promise<ArticleHistoryMap | string> {
539
+ const text = await readText(path);
540
+ if (text === null) return `cannot read ${path}`;
541
+ let json: unknown;
542
+ try {
543
+ json = JSON.parse(text);
544
+ } catch (error) {
545
+ return `${path} is not JSON: ${describeError(error)}`;
546
+ }
547
+ const parsed = parseArticleHistory(json);
548
+ return parsed.ok ? parsed.history : `${path}: ${parsed.errors.join("; ")}`;
519
549
  }
520
550
 
521
551
  interface ReadArticleFile extends ArticleFile {
package/src/cli/skill.ts CHANGED
@@ -69,7 +69,8 @@ export function renderAppSections(sections: readonly BlogSkillSection[]): string
69
69
  /** The values the templates read. */
70
70
  export function getSkillValues(config: SoftureConfig, settings: QualitySettings, options: RenderBlogSkillOptions = {}): SkillValues {
71
71
  const blogOptions = getBlogOptions(config);
72
- const { limits, ymyl, voice, paths, plugins } = settings.options;
72
+ const { limits, ymyl, voice, plugins } = settings.options;
73
+ const { paths } = settings;
73
74
  const fieldKeys = Object.keys(blogOptions.fields?.shape ?? {});
74
75
  const reservedSlugs = getBlogReservedSlugs(config);
75
76
  return {
package/src/contract.ts CHANGED
@@ -84,5 +84,13 @@ export type BlogPublishResult =
84
84
  readonly after: BlogArticleState;
85
85
  /** The slug that has just entered the slug history. */
86
86
  readonly previousSlug: string | null;
87
+ /** Set when the article's history (`publishArticle({ history })`) was applied. */
88
+ readonly imported?: true;
87
89
  }
88
- | { readonly ok: false; readonly error: BlogSlugErrorCode; readonly otherArticleId: string };
90
+ | {
91
+ readonly ok: false;
92
+ readonly error: BlogSlugErrorCode;
93
+ readonly otherArticleId: string;
94
+ /** Set when the slug is an old slug from the article's history, not its current one. */
95
+ readonly slug?: string;
96
+ };