@softure-ai/blog 0.0.0-stage → 0.1.6

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 (419) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +604 -2
  4. package/dist/cli/bin.d.ts +3 -0
  5. package/dist/cli/bin.d.ts.map +1 -0
  6. package/dist/cli/bin.js +5 -0
  7. package/dist/cli/bin.js.map +1 -0
  8. package/dist/cli/command.d.ts +9 -0
  9. package/dist/cli/command.d.ts.map +1 -0
  10. package/dist/cli/command.js +40 -0
  11. package/dist/cli/command.js.map +1 -0
  12. package/dist/cli/index.d.ts +4 -0
  13. package/dist/cli/index.d.ts.map +1 -0
  14. package/dist/cli/index.js +5 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/run.d.ts +62 -0
  17. package/dist/cli/run.d.ts.map +1 -0
  18. package/dist/cli/run.js +555 -0
  19. package/dist/cli/run.js.map +1 -0
  20. package/dist/cli/skill.d.ts +47 -0
  21. package/dist/cli/skill.d.ts.map +1 -0
  22. package/dist/cli/skill.js +221 -0
  23. package/dist/cli/skill.js.map +1 -0
  24. package/dist/content/article-file.d.ts +27 -0
  25. package/dist/content/article-file.d.ts.map +1 -0
  26. package/dist/content/article-file.js +179 -0
  27. package/dist/content/article-file.js.map +1 -0
  28. package/dist/contract.d.ts +77 -0
  29. package/dist/contract.d.ts.map +1 -0
  30. package/dist/contract.js +5 -0
  31. package/dist/contract.js.map +1 -0
  32. package/dist/db/articles.d.ts +30 -0
  33. package/dist/db/articles.d.ts.map +1 -0
  34. package/dist/db/articles.js +138 -0
  35. package/dist/db/articles.js.map +1 -0
  36. package/dist/db/publish-run.d.ts +50 -0
  37. package/dist/db/publish-run.d.ts.map +1 -0
  38. package/dist/db/publish-run.js +168 -0
  39. package/dist/db/publish-run.js.map +1 -0
  40. package/dist/db/schema.d.ts +403 -0
  41. package/dist/db/schema.d.ts.map +1 -0
  42. package/dist/db/schema.js +33 -0
  43. package/dist/db/schema.js.map +1 -0
  44. package/dist/discovery/dates.d.ts +9 -0
  45. package/dist/discovery/dates.d.ts.map +1 -0
  46. package/dist/discovery/dates.js +19 -0
  47. package/dist/discovery/dates.js.map +1 -0
  48. package/dist/discovery/index.d.ts +8 -0
  49. package/dist/discovery/index.d.ts.map +1 -0
  50. package/dist/discovery/index.js +11 -0
  51. package/dist/discovery/index.js.map +1 -0
  52. package/dist/discovery/indexnow.d.ts +11 -0
  53. package/dist/discovery/indexnow.d.ts.map +1 -0
  54. package/dist/discovery/indexnow.js +23 -0
  55. package/dist/discovery/indexnow.js.map +1 -0
  56. package/dist/discovery/refresh.d.ts +49 -0
  57. package/dist/discovery/refresh.d.ts.map +1 -0
  58. package/dist/discovery/refresh.js +55 -0
  59. package/dist/discovery/refresh.js.map +1 -0
  60. package/dist/discovery/related.d.ts +17 -0
  61. package/dist/discovery/related.d.ts.map +1 -0
  62. package/dist/discovery/related.js +28 -0
  63. package/dist/discovery/related.js.map +1 -0
  64. package/dist/discovery/rss.d.ts +30 -0
  65. package/dist/discovery/rss.d.ts.map +1 -0
  66. package/dist/discovery/rss.js +48 -0
  67. package/dist/discovery/rss.js.map +1 -0
  68. package/dist/discovery/sitemap.d.ts +25 -0
  69. package/dist/discovery/sitemap.d.ts.map +1 -0
  70. package/dist/discovery/sitemap.js +31 -0
  71. package/dist/discovery/sitemap.js.map +1 -0
  72. package/dist/discovery/submit.d.ts +44 -0
  73. package/dist/discovery/submit.d.ts.map +1 -0
  74. package/dist/discovery/submit.js +46 -0
  75. package/dist/discovery/submit.js.map +1 -0
  76. package/dist/index.d.ts +371 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +69 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/messages/en.d.ts +83 -0
  81. package/dist/messages/en.d.ts.map +1 -0
  82. package/dist/messages/en.js +83 -0
  83. package/dist/messages/en.js.map +1 -0
  84. package/dist/messages/index.d.ts +158 -0
  85. package/dist/messages/index.d.ts.map +1 -0
  86. package/dist/messages/index.js +5 -0
  87. package/dist/messages/index.js.map +1 -0
  88. package/dist/messages/pl.d.ts +78 -0
  89. package/dist/messages/pl.d.ts.map +1 -0
  90. package/dist/messages/pl.js +78 -0
  91. package/dist/messages/pl.js.map +1 -0
  92. package/dist/next/context.d.ts +6 -0
  93. package/dist/next/context.d.ts.map +1 -0
  94. package/dist/next/context.js +27 -0
  95. package/dist/next/context.js.map +1 -0
  96. package/dist/next/data.d.ts +11 -0
  97. package/dist/next/data.d.ts.map +1 -0
  98. package/dist/next/data.js +40 -0
  99. package/dist/next/data.js.map +1 -0
  100. package/dist/next/discovery.d.ts +6 -0
  101. package/dist/next/discovery.d.ts.map +1 -0
  102. package/dist/next/discovery.js +49 -0
  103. package/dist/next/discovery.js.map +1 -0
  104. package/dist/next/index.d.ts +9 -0
  105. package/dist/next/index.d.ts.map +1 -0
  106. package/dist/next/index.js +10 -0
  107. package/dist/next/index.js.map +1 -0
  108. package/dist/next/og-fonts.d.ts +6 -0
  109. package/dist/next/og-fonts.d.ts.map +1 -0
  110. package/dist/next/og-fonts.js +8 -0
  111. package/dist/next/og-fonts.js.map +1 -0
  112. package/dist/next/og-image.d.ts +36 -0
  113. package/dist/next/og-image.d.ts.map +1 -0
  114. package/dist/next/og-image.js +77 -0
  115. package/dist/next/og-image.js.map +1 -0
  116. package/dist/next/pages.d.ts +45 -0
  117. package/dist/next/pages.d.ts.map +1 -0
  118. package/dist/next/pages.js +189 -0
  119. package/dist/next/pages.js.map +1 -0
  120. package/dist/next/refresh.d.ts +9 -0
  121. package/dist/next/refresh.d.ts.map +1 -0
  122. package/dist/next/refresh.js +81 -0
  123. package/dist/next/refresh.js.map +1 -0
  124. package/dist/options.d.ts +320 -0
  125. package/dist/options.d.ts.map +1 -0
  126. package/dist/options.js +184 -0
  127. package/dist/options.js.map +1 -0
  128. package/dist/pages/body.d.ts +18 -0
  129. package/dist/pages/body.d.ts.map +1 -0
  130. package/dist/pages/body.js +21 -0
  131. package/dist/pages/body.js.map +1 -0
  132. package/dist/pages/dates.d.ts +21 -0
  133. package/dist/pages/dates.d.ts.map +1 -0
  134. package/dist/pages/dates.js +24 -0
  135. package/dist/pages/dates.js.map +1 -0
  136. package/dist/pages/index.d.ts +7 -0
  137. package/dist/pages/index.d.ts.map +1 -0
  138. package/dist/pages/index.js +9 -0
  139. package/dist/pages/index.js.map +1 -0
  140. package/dist/pages/json-ld.d.ts +33 -0
  141. package/dist/pages/json-ld.d.ts.map +1 -0
  142. package/dist/pages/json-ld.js +95 -0
  143. package/dist/pages/json-ld.js.map +1 -0
  144. package/dist/pages/listing.d.ts +46 -0
  145. package/dist/pages/listing.d.ts.map +1 -0
  146. package/dist/pages/listing.js +57 -0
  147. package/dist/pages/listing.js.map +1 -0
  148. package/dist/pages/paths.d.ts +37 -0
  149. package/dist/pages/paths.d.ts.map +1 -0
  150. package/dist/pages/paths.js +61 -0
  151. package/dist/pages/paths.js.map +1 -0
  152. package/dist/pages/redirects.d.ts +57 -0
  153. package/dist/pages/redirects.d.ts.map +1 -0
  154. package/dist/pages/redirects.js +78 -0
  155. package/dist/pages/redirects.js.map +1 -0
  156. package/dist/proxy/index.d.ts +15 -0
  157. package/dist/proxy/index.d.ts.map +1 -0
  158. package/dist/proxy/index.js +60 -0
  159. package/dist/proxy/index.js.map +1 -0
  160. package/dist/quality/blocks.d.ts +21 -0
  161. package/dist/quality/blocks.d.ts.map +1 -0
  162. package/dist/quality/blocks.js +113 -0
  163. package/dist/quality/blocks.js.map +1 -0
  164. package/dist/quality/catalog.d.ts +10 -0
  165. package/dist/quality/catalog.d.ts.map +1 -0
  166. package/dist/quality/catalog.js +71 -0
  167. package/dist/quality/catalog.js.map +1 -0
  168. package/dist/quality/check-article.d.ts +34 -0
  169. package/dist/quality/check-article.d.ts.map +1 -0
  170. package/dist/quality/check-article.js +74 -0
  171. package/dist/quality/check-article.js.map +1 -0
  172. package/dist/quality/check-files.d.ts +23 -0
  173. package/dist/quality/check-files.d.ts.map +1 -0
  174. package/dist/quality/check-files.js +44 -0
  175. package/dist/quality/check-files.js.map +1 -0
  176. package/dist/quality/external-links.d.ts +19 -0
  177. package/dist/quality/external-links.d.ts.map +1 -0
  178. package/dist/quality/external-links.js +30 -0
  179. package/dist/quality/external-links.js.map +1 -0
  180. package/dist/quality/finding.d.ts +17 -0
  181. package/dist/quality/finding.d.ts.map +1 -0
  182. package/dist/quality/finding.js +15 -0
  183. package/dist/quality/finding.js.map +1 -0
  184. package/dist/quality/gate.d.ts +5 -0
  185. package/dist/quality/gate.d.ts.map +1 -0
  186. package/dist/quality/gate.js +10 -0
  187. package/dist/quality/gate.js.map +1 -0
  188. package/dist/quality/index.d.ts +15 -0
  189. package/dist/quality/index.d.ts.map +1 -0
  190. package/dist/quality/index.js +16 -0
  191. package/dist/quality/index.js.map +1 -0
  192. package/dist/quality/link-targets.d.ts +44 -0
  193. package/dist/quality/link-targets.d.ts.map +1 -0
  194. package/dist/quality/link-targets.js +91 -0
  195. package/dist/quality/link-targets.js.map +1 -0
  196. package/dist/quality/options.d.ts +113 -0
  197. package/dist/quality/options.d.ts.map +1 -0
  198. package/dist/quality/options.js +93 -0
  199. package/dist/quality/options.js.map +1 -0
  200. package/dist/quality/plugin.d.ts +34 -0
  201. package/dist/quality/plugin.d.ts.map +1 -0
  202. package/dist/quality/plugin.js +7 -0
  203. package/dist/quality/plugin.js.map +1 -0
  204. package/dist/quality/rules/blocks.d.ts +5 -0
  205. package/dist/quality/rules/blocks.d.ts.map +1 -0
  206. package/dist/quality/rules/blocks.js +35 -0
  207. package/dist/quality/rules/blocks.js.map +1 -0
  208. package/dist/quality/rules/images.d.ts +4 -0
  209. package/dist/quality/rules/images.d.ts.map +1 -0
  210. package/dist/quality/rules/images.js +33 -0
  211. package/dist/quality/rules/images.js.map +1 -0
  212. package/dist/quality/rules/input.d.ts +11 -0
  213. package/dist/quality/rules/input.d.ts.map +1 -0
  214. package/dist/quality/rules/input.js +2 -0
  215. package/dist/quality/rules/input.js.map +1 -0
  216. package/dist/quality/rules/links.d.ts +17 -0
  217. package/dist/quality/rules/links.d.ts.map +1 -0
  218. package/dist/quality/rules/links.js +46 -0
  219. package/dist/quality/rules/links.js.map +1 -0
  220. package/dist/quality/rules/structure.d.ts +13 -0
  221. package/dist/quality/rules/structure.d.ts.map +1 -0
  222. package/dist/quality/rules/structure.js +139 -0
  223. package/dist/quality/rules/structure.js.map +1 -0
  224. package/dist/quality/rules/style.d.ts +11 -0
  225. package/dist/quality/rules/style.d.ts.map +1 -0
  226. package/dist/quality/rules/style.js +129 -0
  227. package/dist/quality/rules/style.js.map +1 -0
  228. package/dist/quality/rules/ymyl.d.ts +5 -0
  229. package/dist/quality/rules/ymyl.d.ts.map +1 -0
  230. package/dist/quality/rules/ymyl.js +51 -0
  231. package/dist/quality/rules/ymyl.js.map +1 -0
  232. package/dist/quality/rulesets/en/ruleset.d.ts +3 -0
  233. package/dist/quality/rulesets/en/ruleset.d.ts.map +1 -0
  234. package/dist/quality/rulesets/en/ruleset.js +106 -0
  235. package/dist/quality/rulesets/en/ruleset.js.map +1 -0
  236. package/dist/quality/rulesets/index.d.ts +7 -0
  237. package/dist/quality/rulesets/index.d.ts.map +1 -0
  238. package/dist/quality/rulesets/index.js +7 -0
  239. package/dist/quality/rulesets/index.js.map +1 -0
  240. package/dist/quality/rulesets/pl/ruleset.d.ts +3 -0
  241. package/dist/quality/rulesets/pl/ruleset.d.ts.map +1 -0
  242. package/dist/quality/rulesets/pl/ruleset.js +114 -0
  243. package/dist/quality/rulesets/pl/ruleset.js.map +1 -0
  244. package/dist/quality/rulesets/types.d.ts +28 -0
  245. package/dist/quality/rulesets/types.d.ts.map +1 -0
  246. package/dist/quality/rulesets/types.js +2 -0
  247. package/dist/quality/rulesets/types.js.map +1 -0
  248. package/dist/quality/settings.d.ts +26 -0
  249. package/dist/quality/settings.d.ts.map +1 -0
  250. package/dist/quality/settings.js +32 -0
  251. package/dist/quality/settings.js.map +1 -0
  252. package/dist/quality/text.d.ts +43 -0
  253. package/dist/quality/text.d.ts.map +1 -0
  254. package/dist/quality/text.js +85 -0
  255. package/dist/quality/text.js.map +1 -0
  256. package/dist/render/glossary.d.ts +27 -0
  257. package/dist/render/glossary.d.ts.map +1 -0
  258. package/dist/render/glossary.js +71 -0
  259. package/dist/render/glossary.js.map +1 -0
  260. package/dist/render/images.d.ts +42 -0
  261. package/dist/render/images.d.ts.map +1 -0
  262. package/dist/render/images.js +84 -0
  263. package/dist/render/images.js.map +1 -0
  264. package/dist/render/index.d.ts +6 -0
  265. package/dist/render/index.d.ts.map +1 -0
  266. package/dist/render/index.js +8 -0
  267. package/dist/render/index.js.map +1 -0
  268. package/dist/render/reading-time.d.ts +8 -0
  269. package/dist/render/reading-time.d.ts.map +1 -0
  270. package/dist/render/reading-time.js +17 -0
  271. package/dist/render/reading-time.js.map +1 -0
  272. package/dist/render/render-article.d.ts +97 -0
  273. package/dist/render/render-article.d.ts.map +1 -0
  274. package/dist/render/render-article.js +373 -0
  275. package/dist/render/render-article.js.map +1 -0
  276. package/dist/render/slugify-heading.d.ts +2 -0
  277. package/dist/render/slugify-heading.d.ts.map +1 -0
  278. package/dist/render/slugify-heading.js +21 -0
  279. package/dist/render/slugify-heading.js.map +1 -0
  280. package/dist/server/health.d.ts +3 -0
  281. package/dist/server/health.d.ts.map +1 -0
  282. package/dist/server/health.js +11 -0
  283. package/dist/server/health.js.map +1 -0
  284. package/dist/server/index.d.ts +10 -0
  285. package/dist/server/index.d.ts.map +1 -0
  286. package/dist/server/index.js +13 -0
  287. package/dist/server/index.js.map +1 -0
  288. package/dist/server/og-fonts.d.ts +24 -0
  289. package/dist/server/og-fonts.d.ts.map +1 -0
  290. package/dist/server/og-fonts.js +85 -0
  291. package/dist/server/og-fonts.js.map +1 -0
  292. package/dist/server/options.d.ts +17 -0
  293. package/dist/server/options.d.ts.map +1 -0
  294. package/dist/server/options.js +56 -0
  295. package/dist/server/options.js.map +1 -0
  296. package/dist/sitemap.d.ts +11 -0
  297. package/dist/sitemap.d.ts.map +1 -0
  298. package/dist/sitemap.js +35 -0
  299. package/dist/sitemap.js.map +1 -0
  300. package/dist/ui/blog-article.d.ts +49 -0
  301. package/dist/ui/blog-article.d.ts.map +1 -0
  302. package/dist/ui/blog-article.js +45 -0
  303. package/dist/ui/blog-article.js.map +1 -0
  304. package/dist/ui/blog-glossary.d.ts +27 -0
  305. package/dist/ui/blog-glossary.d.ts.map +1 -0
  306. package/dist/ui/blog-glossary.js +20 -0
  307. package/dist/ui/blog-glossary.js.map +1 -0
  308. package/dist/ui/blog-layout.d.ts +31 -0
  309. package/dist/ui/blog-layout.d.ts.map +1 -0
  310. package/dist/ui/blog-layout.js +25 -0
  311. package/dist/ui/blog-layout.js.map +1 -0
  312. package/dist/ui/blog-listing.d.ts +15 -0
  313. package/dist/ui/blog-listing.d.ts.map +1 -0
  314. package/dist/ui/blog-listing.js +29 -0
  315. package/dist/ui/blog-listing.js.map +1 -0
  316. package/dist/ui/blog-method.d.ts +5 -0
  317. package/dist/ui/blog-method.d.ts.map +1 -0
  318. package/dist/ui/blog-method.js +21 -0
  319. package/dist/ui/blog-method.js.map +1 -0
  320. package/dist/ui/index.d.ts +7 -0
  321. package/dist/ui/index.d.ts.map +1 -0
  322. package/dist/ui/index.js +8 -0
  323. package/dist/ui/index.js.map +1 -0
  324. package/dist/ui/page-context.d.ts +15 -0
  325. package/dist/ui/page-context.d.ts.map +1 -0
  326. package/dist/ui/page-context.js +2 -0
  327. package/dist/ui/page-context.js.map +1 -0
  328. package/migrations/0001_create_articles.sql +67 -0
  329. package/migrations/README.md +8 -0
  330. package/module.json +28 -0
  331. package/package.json +102 -4
  332. package/skill/SKILL.md +72 -0
  333. package/skill/references/reviewer.md +39 -0
  334. package/skill/references/rules.md +122 -0
  335. package/skill/references/structure.md +69 -0
  336. package/skill/references/template.md +86 -0
  337. package/src/cli/bin.ts +5 -0
  338. package/src/cli/command.ts +49 -0
  339. package/src/cli/index.ts +20 -0
  340. package/src/cli/run.ts +591 -0
  341. package/src/cli/skill.ts +242 -0
  342. package/src/content/article-file.ts +184 -0
  343. package/src/contract.ts +88 -0
  344. package/src/db/articles.ts +160 -0
  345. package/src/db/publish-run.ts +224 -0
  346. package/src/db/schema.ts +36 -0
  347. package/src/discovery/dates.ts +27 -0
  348. package/src/discovery/index.ts +18 -0
  349. package/src/discovery/indexnow.ts +25 -0
  350. package/src/discovery/refresh.ts +79 -0
  351. package/src/discovery/related.ts +35 -0
  352. package/src/discovery/rss.ts +77 -0
  353. package/src/discovery/sitemap.ts +59 -0
  354. package/src/discovery/submit.ts +69 -0
  355. package/src/index.ts +99 -0
  356. package/src/messages/en.ts +82 -0
  357. package/src/messages/index.ts +7 -0
  358. package/src/messages/pl.ts +77 -0
  359. package/src/next/context.ts +30 -0
  360. package/src/next/data.ts +55 -0
  361. package/src/next/discovery.ts +49 -0
  362. package/src/next/index.ts +25 -0
  363. package/src/next/next-modules.d.ts +15 -0
  364. package/src/next/og-fonts.ts +14 -0
  365. package/src/next/og-image.tsx +108 -0
  366. package/src/next/pages.tsx +251 -0
  367. package/src/next/refresh.ts +85 -0
  368. package/src/options.ts +224 -0
  369. package/src/pages/body.ts +38 -0
  370. package/src/pages/dates.ts +39 -0
  371. package/src/pages/index.ts +37 -0
  372. package/src/pages/json-ld.ts +120 -0
  373. package/src/pages/listing.ts +95 -0
  374. package/src/pages/paths.ts +84 -0
  375. package/src/pages/redirects.ts +120 -0
  376. package/src/proxy/index.ts +66 -0
  377. package/src/quality/blocks.ts +130 -0
  378. package/src/quality/catalog.ts +86 -0
  379. package/src/quality/check-article.ts +112 -0
  380. package/src/quality/check-files.ts +63 -0
  381. package/src/quality/external-links.ts +37 -0
  382. package/src/quality/finding.ts +29 -0
  383. package/src/quality/gate.ts +15 -0
  384. package/src/quality/index.ts +28 -0
  385. package/src/quality/link-targets.ts +107 -0
  386. package/src/quality/options.ts +105 -0
  387. package/src/quality/plugin.ts +43 -0
  388. package/src/quality/rules/blocks.ts +43 -0
  389. package/src/quality/rules/images.ts +34 -0
  390. package/src/quality/rules/input.ts +12 -0
  391. package/src/quality/rules/links.ts +56 -0
  392. package/src/quality/rules/structure.ts +137 -0
  393. package/src/quality/rules/style.ts +137 -0
  394. package/src/quality/rules/ymyl.ts +57 -0
  395. package/src/quality/rulesets/en/ruleset.ts +117 -0
  396. package/src/quality/rulesets/index.ts +9 -0
  397. package/src/quality/rulesets/pl/ruleset.ts +126 -0
  398. package/src/quality/rulesets/types.ts +31 -0
  399. package/src/quality/settings.ts +60 -0
  400. package/src/quality/text.ts +113 -0
  401. package/src/render/glossary.ts +103 -0
  402. package/src/render/images.ts +110 -0
  403. package/src/render/index.ts +29 -0
  404. package/src/render/reading-time.ts +18 -0
  405. package/src/render/render-article.ts +487 -0
  406. package/src/render/slugify-heading.ts +22 -0
  407. package/src/server/health.ts +12 -0
  408. package/src/server/index.ts +28 -0
  409. package/src/server/og-fonts.ts +102 -0
  410. package/src/server/options.ts +62 -0
  411. package/src/sitemap.ts +36 -0
  412. package/src/ui/blog-article.tsx +186 -0
  413. package/src/ui/blog-glossary.tsx +104 -0
  414. package/src/ui/blog-layout.tsx +89 -0
  415. package/src/ui/blog-listing.tsx +95 -0
  416. package/src/ui/blog-method.tsx +34 -0
  417. package/src/ui/index.ts +8 -0
  418. package/src/ui/page-context.ts +17 -0
  419. package/styles.css +498 -0
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ Newest first. Each version lists what changed for an app that uses `@softure-ai/blog`. When an app has run a version in
4
+ production, the version gets a line `verified in: <app>@<commit>` ([docs/05](../../docs/05-adoption-playbook.md),
5
+ "Definition of done"). Versions before the first one below are described in their GitHub Releases (`blog@x.y.z`).
6
+
7
+ ## 0.1.6
8
+
9
+ - Adapters and commands use the configured database handle.
10
+ - `@softure-ai/ui` is a peer dependency; the package keeps its own CSS.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 SOFTURE
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,605 @@
1
- # Temporary Holding Version
1
+ # @softure-ai/blog
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Articles and glossary terms kept as Markdown files in the app's repository, and a publish command that
4
+ brings the module's tables to the state of those files. The files are the source of truth: there is no
5
+ editor and no CMS, a text changes only through a commit and `softure-blog publish`.
6
+
7
+ ## 1. What it provides
8
+
9
+ - A strict article file format: a YAML frontmatter with English keys (an unknown key is an error),
10
+ extendable by the app's own fields, then the Markdown body.
11
+ - `blog.articles` and `blog.slug_history` with database constraints for every invariant that fits one.
12
+ - `softure-blog publish`: a dry run by default; with `--commit`, all files or none; unchanged files are
13
+ skipped by their content hash; a slug change keeps the old slug as a redirect; one pillar per cluster.
14
+ - Read functions for the pages: `getPublishedArticle`, `findArticleBySlug`, `findSlugRedirect`,
15
+ `listArticles`.
16
+ - `renderArticle(markdown, options)`: the body as safe HTML on the server (no raw HTML, safe link
17
+ schemes only, marked external links, images under the app's image policy), heading ids and an
18
+ 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.
20
+ - Pages, each mounted with one re-export line (`@softure-ai/blog/next`): the listing grouped by cluster
21
+ with the pillar first, an article (dates, summary, contents, FAQ, sources, signature, disclaimer,
22
+ `BlogPosting`/`BreadcrumbList`/`FAQPage` JSON-LD), the glossary index and a term page (`DefinedTerm`,
23
+ the articles that explain it), the optional "how our texts are made" page, an article's OG card.
24
+ Their canonical, Open Graph, JSON-LD and feed URLs follow `@softure-ai/seo`'s origin, host and
25
+ trailing-slash rule when the app lists `seo()` (core's `getSiteUrls`), and `appOrigin` otherwise.
26
+ - `createBlogRedirects` (`@softure-ai/blog/proxy`): 301 from an old slug, 410 for a withdrawn text,
27
+ in the app's `proxy.ts`.
28
+ - `@softure-ai/blog/styles.css`: the pages and the rendered body on the `--sft-*` tokens.
29
+ - Discovery: an RSS 2.0 feed (`serveBlogRss`), "read next" under every article (its cluster first, the
30
+ pillar on top), and, with `@softure-ai/seo` (optional): sitemap entries with each text's real
31
+ `lastmod` (`blogSitemap()`) and an IndexNow submit of the changed addresses after
32
+ `softure-blog publish --commit` (`submitBlogChanges` for an app's own publishing path).
33
+ - A cache refresh route (`refreshBlogCache`, rate-limited through `@softure-ai/security`, a secret from
34
+ `BLOG_REFRESH_SECRET`): `softure-blog publish --commit` calls it before the IndexNow submit, so the
35
+ running app shows the change at once instead of after `revalidateSeconds`.
36
+ - A text quality gate: `softure-blog check` reports structure, link, style, voice and YMYL findings
37
+ with file and line, and `publish` refuses a text going public with an error. Language rulesets
38
+ (`en`, `pl`), severity overrides and rule plugins for the app's own domain.
39
+ - `softure-blog skill install`: an agent skill for writing the texts, generated from the gate's rules
40
+ and the app's config, with `--check` for CI.
41
+
42
+ ## 2. Installation
43
+
44
+ ```bash
45
+ npm install @softure-ai/blog @softure-ai/ui
46
+ ```
47
+
48
+ `@softure-ai/ui` is a peer dependency (any 0.1.x), like the optional `@softure-ai/security` and
49
+ `@softure-ai/seo`: the app installs it once, so the theme tokens come from one copy. Importing
50
+ `@softure-ai/blog/styles.css` from JavaScript is safe with tree-shaking, because the package marks its CSS as a side
51
+ effect.
52
+
53
+ Then add `blog()` to the modules of `softure.config.ts` and run `softure migrate`.
54
+
55
+ ## 3. Configuration
56
+
57
+ ```ts
58
+ import { blog } from "@softure-ai/blog";
59
+ import { pl } from "./messages/pl";
60
+ import { z } from "zod";
61
+
62
+ blog({
63
+ // The folder `softure-blog publish` reads when no path is given. Default: "content/blog".
64
+ contentDir: "content/blog",
65
+ // Extra slugs an article may not take. The routes' own segments (the glossary, the method page
66
+ // when on) are reserved by themselves. Default: none.
67
+ reservedSlugs: [],
68
+ // The app's own frontmatter keys, checked by the app's schema. Default: none.
69
+ fields: z.object({ scenario: z.string().regex(/^[a-z]=\d+(&[a-z]=\d+)*$/).optional() }),
70
+ // The pages' brand: title suffix, signature, JSON-LD author and publisher, OG card colours (hex)
71
+ // 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" } },
73
+ // Mount the method page at routes.method. Default: false (the route answers 404).
74
+ methodPage: true,
75
+ // A note under every article and term, per locale (en required). Default: none.
76
+ disclaimer: { en: "Education, not financial advice.", pl: pl.blog.disclaimer },
77
+ // The heading of each cluster on the listing, per locale; a missing key shows the key. Default: {}.
78
+ clusters: { "investing-basics": { en: "Investing basics", pl: pl.blog.investingBasics } },
79
+ // Block plugins of renderArticle, used by the pages. Default: [].
80
+ blocks: [],
81
+ // Hosts of the app besides APP_ORIGIN's and seo's canonical host, whose links are not external. Default: [].
82
+ siteHosts: ["www.example.com"],
83
+ // Which images bodies may show: site paths and https images on these hosts (subdomains included),
84
+ // with a width and height the app knows. Used by the pages and the quality gate. Default: none
85
+ // (every image renders as its alt text and the gate refuses it).
86
+ images: { hosts: ["cdn.example.com"], dimensions: (src) => imageSizes[src] ?? null },
87
+ // How long the cached reads hold; keep equal to the pages' `revalidate`. Default: 300.
88
+ revalidateSeconds: 300,
89
+ // The app's own sections of the generated writing skill (see "The writing skill"). Default: none.
90
+ skill: { sections: [] },
91
+ // Every route can move: blog({ routes: { index: "/articles" } }).
92
+ });
93
+ ```
94
+
95
+ Routes: `index` `/blog` (articles at `/blog/<slug>`), `glossary` `/blog/glossary` (terms at
96
+ `/blog/glossary/<slug>`), `method` `/blog/how-we-write`.
97
+
98
+ `fields` may not reuse a key of the module (`FRONTMATTER_KEYS`). Its parsed value is stored in
99
+ `articles.fields`, enters the content hash and must be plain JSON.
100
+
101
+ ### The quality gate
102
+
103
+ On by default with the `en` ruleset; `quality: false` turns it off. Every key is optional:
104
+
105
+ ```ts
106
+ blog({
107
+ quality: {
108
+ language: "pl", // the ruleset: "en" (default) or "pl"
109
+ ymyl: { ownCalculationMark: "our calculation" }, // or true / false (default): sources, sourced numbers, no profit promises
110
+ voice: {
111
+ forbidFirstPersonSingular: true, // texts signed by the editors: no "I", "my"
112
+ // a global RegExp; wordPattern (from @softure-ai/blog) adds the i flag and word edges in any alphabet
113
+ phrases: [{ id: "finance-cliche", pattern: wordPattern("in the world of finance"), message: "say what happens instead" }],
114
+ },
115
+ limits: { words: { article: { min: 600, max: 4000 } }, answerWords: 70 }, // FIRE's values are the defaults
116
+ 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
118
+ ownOrigins: ["https://www.example.com"], // absolute links that count as internal, besides appOrigin and seo's origin
119
+ appDir: "src/app", // routes for internal links; default src/app, else app
120
+ privateRouteSegments: ["api", "(app)"], // route folders that are no link target; default ["api"]
121
+ plugins: [factsPlugin], // the app's own rules, see Hooks
122
+ blocks: [chartBlock], // the block plugins of renderArticle: their requires are checked
123
+ },
124
+ });
125
+ ```
126
+
127
+ The rules, by group (`listQualityRules(getQualitySettings(config))` lists them with their effective
128
+ severity; the writing skill is kept in step with it):
129
+
130
+ | Group | Rules (errors **bold**) |
131
+ | --- | --- |
132
+ | file | **`file`**: the frontmatter parses and the slug equals the file name |
133
+ | structure | `title-length`, `description-length`, **`as-of-future`**, `stale`, **`summary-missing`**, **`lead`** (a paragraph first), `lead-length`, **`lead-number`**, **`heading-h1`**, **`heading-order`**, **`sections`** (two `##`), **`section-question`**, **`section-answer`**, `section-answer-length`, **`length`** (a warning above the maximum), **`footnote-undefined`**, `footnote-unused` |
134
+ | links | **`internal-links`** (a warning for a term), **`internal-link-target`** (`check` only), `external-link-https`, **`external-link-dead`** (`--external` only), **`term-form-conflict`** (`check` only: a checked published term shares a form with another published term; `publish` refuses it whatever its severity) |
135
+ | images | **`image-source`** (a site path or a host of `blog({ images })`; every image while the app has no policy), **`image-alt`**, **`image-dimensions`** (the policy's `dimensions` knows it) |
136
+ | style (ruleset) | **`announcement`**, **`these-days`**, **`not-only-but-also`**, **`not-x-but-y`**, **`meta-commentary`**, **`throat-clearing`**, **`empty-conclusion`**, **`crucial`**, **`plays-a-role`**, **`puffery`**, **`chatbot-phrases`**, **`emoji`**, `filler-words`, `exclamation`, `straight-quotes` (`pl`), `title-case-heading` (`pl`) |
137
+ | style (rhythm) | **`dashes`**, `dashes-paragraph`, `bold-density`, `bold-labels`, `triads`, `long-sentences`, `monotone-rhythm`, `repeated-openings` |
138
+ | voice | **`first-person-singular`** and the app's phrases, when configured |
139
+ | ymyl | **`sources-missing`**, **`source-https`**, **`number-source`**, **`footnote-source`**, **`footnote-not-in-sources`**, **`profit-promise`**, when `ymyl` is on |
140
+ | blocks | **`block-requires`**: a fenced block of a block plugin has the frontmatter keys it `requires`, when `blocks` is set |
141
+ | plugin | the plugins' rules, **`plugin-failed`**, **`plugin-rule-undeclared`** |
142
+
143
+ Style patterns match the prose of the body and the title, description and summary (errors only
144
+ there). A warning pattern is reported once per text with its count. A significant number is an
145
+ amount, a percentage or a number from 1000 up, in the ruleset's notation; years, ages, small counts
146
+ and legal references ("art. 27", "section 401") need no source. Messages are English: they are read
147
+ by developers and by the agents that write the texts.
148
+
149
+ ### The article file
150
+
151
+ One file per text, named `<slug>.md`:
152
+
153
+ ```markdown
154
+ ---
155
+ id: index-funds # stable key, given once; never change it
156
+ slug: index-funds # the address; equals the file name without .md
157
+ kind: article # article | term (a glossary definition); default article
158
+ forms: [tax wrapper] # term only, required: the phrases that link to the definition
159
+ cluster: investing-basics # the topic for "read next" lists; optional
160
+ pillar: true # the main text of its cluster: one per cluster, needs cluster; default false
161
+ title: Index funds in plain words
162
+ description: One or two sentences for search results and the social card.
163
+ summary: A few sentences with numbers for the "in short" box; optional.
164
+ status: published # draft | published | withdrawn
165
+ current_as_of: 2026-10-01 # the day the facts were checked
166
+ published_at: 2026-10-01 # optional; a day (midnight UTC) or a moment with an offset; default: first publication
167
+ sources:
168
+ - name: Fund factsheet
169
+ url: https://example.com/factsheet
170
+ faq:
171
+ - question: Is it safe?
172
+ answer: It follows the market, up and down.
173
+ ---
174
+
175
+ The Markdown body.
176
+ ```
177
+
178
+ Rules:
179
+
180
+ - **Slug change:** change `slug` and rename the file, keep `id`. The old slug goes to the slug history
181
+ and redirects to the new one. A slug another article has now, or had before, is refused.
182
+ - **Glossary forms:** a form belongs to one published term (forms equal up to a capital first letter
183
+ are one). `publish` refuses a run that leaves a form with two terms, one of them in the run, naming
184
+ the form and both slugs; a conflict only between stored terms is a warning. `check` reports it as
185
+ `term-form-conflict`. The renderer links such a form to the first term it was given.
186
+ - **Withdrawal:** `status: withdrawn`. The row stays and its address answers 410. Do not delete the
187
+ file: a deleted file changes nothing in the database.
188
+ - **Update date:** `updated_at` moves by itself when the content of a published text changes (title,
189
+ description, summary, body, date of the facts, sources, FAQ, cluster, forms, fields), never on a
190
+ status, slug, publication date or pillar change alone.
191
+
192
+ ## 4. Mounting
193
+
194
+ Each page is one file in the app. Next reads `dynamic` and `revalidate` only as literals in the app's
195
+ own file, so they stay there; `revalidate` should equal `revalidateSeconds`.
196
+
197
+ ```tsx
198
+ // app/blog/page.tsx: the listing, rendered per request over cached reads
199
+ export { BlogIndexPage as default, generateBlogIndexMetadata as generateMetadata } from "@softure-ai/blog/next";
200
+ export const dynamic = "force-dynamic";
201
+
202
+ // app/blog/[slug]/page.tsx: an article, kept for 300 s (ISR); slots take the app's components
203
+ import { BlogArticlePage, type BlogArticlePageProps } from "@softure-ai/blog/next";
204
+ export { generateArticleMetadata as generateMetadata, generateBlogStaticParams as generateStaticParams } from "@softure-ai/blog/next";
205
+ export const revalidate = 300;
206
+ export default function Page({ params }: Pick<BlogArticlePageProps, "params">) {
207
+ return <BlogArticlePage params={params} cta={<MyCta />} afterArticle={<Waitlist placement="blog" />} />;
208
+ }
209
+
210
+ // app/blog/[slug]/opengraph-image.tsx
211
+ export { BlogArticleOgImage as default, generateBlogStaticParams as generateStaticParams } from "@softure-ai/blog/next";
212
+ export const size = { width: 1200, height: 630 };
213
+ export const contentType = "image/png";
214
+ export const revalidate = 300;
215
+
216
+ // app/blog/glossary/page.tsx GlossaryIndexPage, generateGlossaryIndexMetadata; dynamic = "force-dynamic"
217
+ // app/blog/glossary/[slug]/page.tsx GlossaryTermPage, generateTermMetadata, generateBlogStaticParams; revalidate
218
+ // app/blog/how-we-write/page.tsx BlogMethodPage, generateMethodMetadata (with methodPage: true)
219
+
220
+ // app/blog/rss.xml/route.ts: the feed of published articles and terms, linked from the listing and articles
221
+ export { serveBlogRss as GET } from "@softure-ai/blog/next";
222
+ export const dynamic = "force-dynamic";
223
+ ```
224
+
225
+ With `@softure-ai/seo`, the blog joins its sitemap through a contributor; `app/sitemap.ts` must then be
226
+ `force-dynamic` (the contributor reads the database):
227
+
228
+ ```ts
229
+ // softure.config.ts (the root entry: this file also loads in plain Node and in bundles outside Next)
230
+ import { blog, blogSitemap } from "@softure-ai/blog";
231
+ seo({ sitemap: { contributors: [blogSitemap()] }, indexNow: { key: "..." } });
232
+ ```
233
+
234
+ The entries are the listing, the articles, the glossary and its terms, each dated by its last content
235
+ change (`updated_at`, else `published_at`), and the method page without a date; an empty listing or
236
+ glossary is left out (it is `noindex`). Paths only: seo makes them absolute with its canonical rule.
237
+ The contributor reads with one query per sitemap request, not through the pages' Next cache.
238
+
239
+ A publish from the command runs outside the app and cannot reach its cache. To show the change at once
240
+ instead of after `revalidateSeconds`, mount the refresh route, list `security()` with the blog's bucket,
241
+ and set one secret (32+ characters, e.g. `openssl rand -base64 32`) as `BLOG_REFRESH_SECRET` for both the
242
+ running app and the command:
243
+
244
+ ```ts
245
+ // app/api/blog/refresh/route.ts (routes.refresh, default /api/blog/refresh)
246
+ export { refreshBlogCache as POST } from "@softure-ai/blog/next";
247
+
248
+ // softure.config.ts
249
+ import { BLOG_RATE_LIMIT_BUCKETS, blog } from "@softure-ai/blog";
250
+ security({ clientIp: cloudflareIp(), buckets: { ...BLOG_RATE_LIMIT_BUCKETS } });
251
+ ```
252
+
253
+ The route counts every request in the `blog-refresh` bucket (10 per 15 minutes per client address;
254
+ callers without one, such as the command on a private network name, share one count) before it checks
255
+ `Authorization: Bearer <secret>`, then expires the blog's cache tag at once (`revalidateTag(BLOG_CACHE_TAG,
256
+ { expire: 0 })`: the next request reads the tables, the cached pages included). Answers: 204 refreshed,
257
+ 401 a missing or wrong secret, 429 over the bucket (`retry-after`), 503 when counting fails, 500 when
258
+ `BLOG_REFRESH_SECRET` is unset or short (logged by name). Without `security()` or its bucket the route
259
+ throws a setup error naming the fix.
260
+
261
+ 301 and 410 are answered before the page, in `proxy.ts` (Node.js runtime, Next 16):
262
+
263
+ ```ts
264
+ import { createBlogRedirects } from "@softure-ai/blog/proxy";
265
+ const blogRedirects = createBlogRedirects(softureConfig);
266
+
267
+ export async function proxy(request: NextRequest) {
268
+ return (await blogRedirects(request)) ?? NextResponse.next();
269
+ }
270
+ ```
271
+
272
+ It handles GET and HEAD on the blog's text paths only, keeps the query on a redirect, remembers a
273
+ decision for 60 s (`ttlMs`) and passes a request on when the database fails. Import the styles after
274
+ ui's: `@import "@softure-ai/blog/styles.css";`. A data change shows after `revalidateSeconds`, or at
275
+ 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
+
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.
279
+
280
+ The commands:
281
+
282
+ ```bash
283
+ softure-blog publish [<path>...] [--commit] [--withdraw] [--no-indexnow] [--app-url <origin>] [--config <file>]
284
+ softure-blog check [<path>...] [--external] [--today <YYYY-MM-DD>] [--config <file>]
285
+ softure-blog skill install [--dir <path>] [--command <cmd>] [--check] [--config <file>]
286
+ ```
287
+
288
+ - `<path>` is a file or a folder (every `*.md` but `README.md`, by name); without one, `contentDir`.
289
+ - `--commit` writes; without it the command prints what it would do and writes nothing.
290
+ - `--withdraw` publishes the one given file as `withdrawn` (taking a text down at once); set the file's
291
+ status too, or the next full publish brings the text back.
292
+ - A done run prints one `cache:` line. With `BLOG_REFRESH_SECRET` set, a commit that changed a text
293
+ posts to the app's refresh route on `appOrigin` (`--app-url <origin>` for another way in, such as
294
+ `http://web:3000` in a container network; redirects are not followed) before the IndexNow submit; a
295
+ dry run prints the address. Without the secret the line says the app shows the change after
296
+ `revalidateSeconds`. A failed refresh is a warning naming the answer: the publish stays written, the
297
+ IndexNow submit still goes out and the exit code stays 0. `--no-indexnow` does not skip it.
298
+ - With `seo({ indexNow: { key } })` enabled, a run ends with one `indexnow:` line. A commit submits the
299
+ addresses whose answer changed (a text public before or after, its old slug after a rename, the
300
+ listing or the glossary of its kind) as canonical URLs on seo's origin; a dry run prints them;
301
+ `--no-indexnow` skips the submit (e.g. a local or CI database). A failed submit is a warning: the
302
+ publish stays written and the exit code stays 0.
303
+ - Exit codes: 0 done, 1 refused or failed (nothing written), 2 usage error.
304
+
305
+ Output, one line per text, then a summary:
306
+
307
+ ```text
308
+ added index-funds none -> published/index-funds
309
+ changed bonds draft/bonds -> published/bonds
310
+ moved bonds bond-basics -> bonds
311
+ summary: added 1, changed 1, unchanged 12
312
+ dry run: nothing written; pass --commit to write
313
+ ```
314
+
315
+ Like `softure migrate`, the bin loads `softure.config.(ts|mts|js|mjs)` with Node and opens
316
+ `database.handle` when the config sets one, otherwise `database.url`. When Node cannot load the config (path aliases, a bundled container), call
317
+ `runBlogCli` from an app script:
318
+
319
+ ```ts
320
+ // scripts/blog.ts
321
+ import { runBlogCli } from "@softure-ai/blog/cli";
322
+ import config from "../softure.config";
323
+
324
+ process.exitCode = await runBlogCli({ config, argv: process.argv.slice(2) });
325
+ ```
326
+
327
+ `publish` runs the quality gate on every file going public (status `published`, not `--withdraw`);
328
+ one error refuses the run. The gate in `publish` does not resolve internal link targets, since a
329
+ container that publishes may hold no app folder; run `check` in CI for those. `runBlogCli({ gate })`
330
+ replaces the gate; `runBlogPublish` in `@softure-ai/blog/server` is the same run without a command line.
331
+ After an app's own run, `submitBlogChanges(config, run.changes, { commit: run.committed })` submits the
332
+ same addresses; inside Next, call `revalidateTag(BLOG_CACHE_TAG, { expire: 0 })` first, and outside it
333
+ `requestBlogRefresh(config, run.changes, { commit: run.committed })` (`@softure-ai/blog/server`), so a
334
+ crawler that answers the ping at once gets the new text.
335
+
336
+ `check` and `skill install` need no database, nor a database URL: the bin loads the config with the
337
+ database optional for them (`@softure-ai/core`'s `withDatabaseOptional`), so a CI job without
338
+ `DATABASE_URL` runs them. An app script that runs them wraps its own import the same way:
339
+ `const { default: config } = await withDatabaseOptional(() => import("../softure.config"))`. `check` reads the
340
+ files (default: `contentDir`), resolves internal links against the app's routes and the published texts
341
+ of `contentDir`, compares the glossary forms of the checked terms with every published term there, reads
342
+ every `brand.fonts` source as the OG card's route does (a path from the working directory, an `https`
343
+ URL fetched, so a job with such a font needs network), and prints one line per finding:
344
+
345
+ ```text
346
+ content/blog/index-funds.md:12: error [crucial] "crucial": a favourite word of language models; name what depends on the thing
347
+ content/blog/bonds.md: OK
348
+ check: 2 file(s), 1 error(s), 0 warning(s): red, do not publish
349
+ ```
350
+
351
+ Exit codes: 0 green (warnings allowed), 1 an error, 2 usage error. `--external` also requests every
352
+ external link (HEAD, then GET when a server refuses HEAD; 2xx after redirects). Run it weekly with the
353
+ reusable workflow of this repository, `.github/workflows/blog-links.yml` (its header holds the
354
+ snippet for the app).
355
+
356
+ ### The writing skill
357
+
358
+ `softure-blog skill install` writes an agent skill for writing the blog's texts into
359
+ `.claude/skills/blog-write/` (`--dir` to change). It walks the agent through a text: the question,
360
+ facts with sources, a draft by an answer-first structure, a rewrite by the rules, `check`, a
361
+ sceptical second agent with its own prompt, `check --external`, and `publish`. The package ships the
362
+ templates in `skill/`; the command fills them from the app's config:
363
+
364
+ - the language of the texts, the content folder, the article and glossary paths and every limit;
365
+ - `references/rules.md`: exactly the rules the app's gate enforces (`listQualityRules`), each with
366
+ its effective severity, what the gate looks for and what to write instead; the app's voice
367
+ phrases and plugin rules with their own descriptions; a rule set to `"off"` is left out;
368
+ - the YMYL passages (sources, footnotes, the own calculation mark) only when `ymyl` is on, and the
369
+ editors' "we" when `voice.forbidFirstPersonSingular` is on;
370
+ - the app's own sections from `blog({ skill: { sections } })` (below).
371
+
372
+ The app adds its own procedure (where its numbers come from, its block plugins, its fields) as sections
373
+ in the config, so a reinstall keeps them and `--check` covers them:
374
+
375
+ ```ts
376
+ blog({
377
+ skill: {
378
+ sections: [
379
+ { title: "Engine numbers", body: "Every number of an example comes from `npm run engine -- <inputs>`." },
380
+ { title: "Chart block", body: "One `::chart{scenario=\"…\"}` block after the lead, with the frontmatter's `scenario`." },
381
+ ],
382
+ },
383
+ });
384
+ ```
385
+
386
+ Install writes them to `references/app.md` (`## <title>` and the body, verbatim, never filled like the
387
+ templates) and `SKILL.md` names them; with no sections the file is not written. A title is one line, unique,
388
+ up to 80 characters; a body holds no `#` or `##` heading outside fenced code (use `###`). An app with long
389
+ sections keeps them in a module of its own and imports them into the config.
390
+
391
+ `--command` sets how the skill runs the commands (default `npx softure-blog`; an app with a
392
+ `runBlogCli` script passes e.g. `--command "npm run blog --"`). Commit the folder, so agents in a
393
+ fresh clone have it, and run `softure-blog skill install --check` (with the same options) in CI, with no
394
+ `DATABASE_URL` needed: it writes nothing and exits 1, naming the files, when the folder differs from what
395
+ the config gives.
396
+ Install overwrites only a folder whose `SKILL.md` it generated, so it never replaces a skill the app
397
+ wrote itself. That folder belongs to the command: install removes a `.md` file in it that the config no
398
+ longer gives (`references/app.md` once the sections are gone), logging `removed <path>`, and `--check`
399
+ names such a file. With `quality: false` it refuses: the skill is built on the gate.
400
+
401
+ ### Rendering an article
402
+
403
+ ```ts
404
+ import { getPublishedArticle, listArticles, renderArticle, toGlossary } from "@softure-ai/blog/server";
405
+
406
+ const article = await getPublishedArticle(ctx, slug);
407
+ const glossary = toGlossary(await listArticles(ctx, { kind: "term" }));
408
+ const body = renderArticle(article.bodyMarkdown, {
409
+ glossary,
410
+ selfSlug: article.kind === "term" ? article.slug : undefined,
411
+ termHref: (term) => `/blog/glossary/${term}`, // the default
412
+ siteHosts: ["example.com"], // subdomains included; other hosts are external
413
+ images: { hosts: ["cdn.example.com"], dimensions: (src) => imageSizes[src] ?? null }, // see Images below
414
+ toc: true, // or { maxLevel: 4 }; h2 and h3 by default
415
+ messages: blogMessages.pl.render, // English by default
416
+ blocks: [chartBlock],
417
+ article: { currentAsOf: article.currentAsOf, fields: article.fields },
418
+ });
419
+ // body.html (null when a block returned a node), body.segments, body.headings, body.toc,
420
+ // body.linkedTerms, body.readingMinutes
421
+ ```
422
+
423
+ - **Allowlist by construction:** raw HTML in the text is escaped, so the output holds only the
424
+ elements Markdown produces. Links keep `http(s)`, `mailto`, relative and `#` targets; any other
425
+ scheme (`javascript:`, `data:`, entity-encoded or split by whitespace) stays text.
426
+ - **Images** (`![alt](src "title")`) follow the image policy (`images`): the source is a site path
427
+ (`/images/x.png`; never `//host` or a path relative to the page) or an `https:` URL on a host in
428
+ `images.hosts` (subdomains included), the alt text is not empty, and `images.dimensions(src)` returns
429
+ positive whole `{ width, height }`. Such an image renders as `<img class="blog-image">` with its
430
+ `width`, `height`, `loading="lazy"` and `decoding="async"`; any other renders as its alt text, and
431
+ without `images` every image does. `src` is the URL the page requests (percent-encoded: a space is
432
+ `%20`). A throwing `dimensions` fails the render (a bug); the gate reports it as `image-dimensions`.
433
+ `checkArticleImage` and `findArticleImages` give the same verdict and the images of a text.
434
+ - **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)".
436
+ - **Headings** get ids from their text (letters folded to ASCII, `-2` for a repeat, `section` without
437
+ letters); `toc` renders `<nav class="blog-toc">` with nested lists.
438
+ - **Glossary:** the first mention of each term form links to its definition; never inside headings,
439
+ links, code or footnotes, never a term page to itself. The longest form wins, word bounds are
440
+ Unicode-aware, case is as written (plus a capital first letter). A hand-written link to a term counts.
441
+ - **Footnotes** (`[^id]`) become numbered references and a notes section with links back.
442
+
443
+ **Block plugins.** A top-level fence whose type an app registers is rendered by the app:
444
+
445
+ ````md
446
+ ```chart wealth
447
+ scenario: early-retirement
448
+ ```
449
+ ````
450
+
451
+ ```ts
452
+ const chartBlock: BlockPlugin = {
453
+ type: "chart",
454
+ requires: ["current_as_of", "scenario"], // frontmatter keys the block reads, for the quality gate
455
+ render: ({ info, content, article }) => ({ kind: "html", html: renderChart(info, content, article) }),
456
+ // or { kind: "node", node: <Chart … /> } for a React server component
457
+ };
458
+ ```
459
+
460
+ Plugin output is the app's own code and is trusted as is: escape what goes into its HTML. A plugin
461
+ 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).
465
+
466
+ ## 5. Migrations and tables
467
+
468
+ Schema `blog`, migration `0001_create_articles.sql`:
469
+
470
+ | Table | Holds |
471
+ | --- | --- |
472
+ | `blog.articles` | one row per text: id, slug, kind, cluster, pillar, title, description, summary, body, status, `current_as_of`, `published_at`, `updated_at`, sources, FAQ, term forms, the app's fields, content hash, `created_at` |
473
+ | `blog.slug_history` | each old slug with its article (the 301 target); removed with the article |
474
+
475
+ Constraints: id, slug, cluster and old slug kebab-case (at most 100 characters); kind and status
476
+ closed lists; text lengths; a published text has a publication date; an update date needs one; a
477
+ pillar has a cluster; a term has forms and an article has none; a unique slug; one pillar per cluster
478
+ among texts not withdrawn (an exclusion constraint deferred to commit, so one run can move the pillar).
479
+ "A slug is not in another article's history" stays in the code, under a row lock; the check reads the current
480
+ slugs before the old ones, so a slug that another run is renaming away from is refused, naming that article,
481
+ whether the rename has committed or not. A run that loses a race for
482
+ a slug (another run committed it between the read and the write) is refused with `blog.slug_taken`,
483
+ naming the article that took it, like any other taken slug.
484
+
485
+ ## 6. Environment variables
486
+
487
+ | Variable | Required | Read by |
488
+ | --- | --- | --- |
489
+ | `BLOG_REFRESH_SECRET` | no | the refresh route (`refreshBlogCache`) and `softure-blog publish`: the shared secret, 32+ characters; without it a publish shows after `revalidateSeconds` |
490
+
491
+ The command reads the database URL from the app's config.
492
+
493
+ ## 7. Switches
494
+
495
+ None.
496
+
497
+ ## 8. Appearance
498
+
499
+ `styles.css` styles every `blog-*` class of the pages and of the rendered body (`blog-external`,
500
+ `blog-external-marker`, `blog-visually-hidden`, `blog-term`, `blog-toc`, `blog-footnote-ref`,
501
+ `blog-footnotes`, `blog-footnote-back`) with the `--sft-*` tokens of `@softure-ai/ui`, in the
502
+ `softure` layer, so the app's own rules win. The OG card takes `brand.colors`, else ui's dark theme.
503
+
504
+ The OG card writes in `brand.fonts`, else in `next/og`'s default font:
505
+
506
+ ```ts
507
+ blog({
508
+ brand: {
509
+ name: "FIRE Tracker",
510
+ fonts: [
511
+ // weight: 100…900 (default 400), style: "normal" | "italic" (default "normal")
512
+ { name: "Inter", weight: 400, src: "assets/fonts/inter-latin-400-normal.woff" },
513
+ { name: "Inter", weight: 700, src: "assets/fonts/inter-latin-700-normal.woff" },
514
+ // a second file of one weight (a latin-ext subset) under its own name: the card lists every name
515
+ // in order, so it draws the characters the first file lacks
516
+ { name: "Inter Ext", weight: 700, src: "https://cdn.example.com/inter-latin-ext-700-normal.woff" },
517
+ ],
518
+ },
519
+ });
520
+ ```
521
+
522
+ - `src` is a `.ttf`, `.otf` or `.woff` file (Satori does not read `.woff2`): a path from the app's root,
523
+ an absolute path, or an `https` URL. Marketing-kit's subset files (`brand.fonts` of `marketing.json`)
524
+ fit as they are.
525
+ - The card's route reads each file on its first card and keeps it for the life of the process. A file
526
+ that cannot be read, or is not such a font, fails the card with a message naming `brand.fonts[i]` and
527
+ the file; the next card tries again. `softure-blog check` reads the same sources, so CI reports such a
528
+ file first (`softure-blog check: Blog OG card: brand.fonts[0] …`, one error, exit 1).
529
+ - Paths are read on the Node.js runtime (the route's default). With `output: "standalone"`, list the
530
+ folder in `outputFileTracingIncludes` (`{ "/blog/[slug]/opengraph-image": ["./assets/fonts/**"] }`);
531
+ a route moved to the edge runtime takes `https` URLs only.
532
+ - With brand fonts the card has no other font: a character none of them has is not drawn.
533
+
534
+ ## 9. Copy
535
+
536
+ `src/messages/`: labels of the kinds (`kinds.article`, `kinds.term`) and statuses (`statuses.*`) in
537
+ `en` and `pl`; the renderer's copy under `render.*` (notes heading, footnote label with `{number}`,
538
+ back to text, opens in a new tab, contents label), passed as `renderArticle({ messages })`; the pages'
539
+ copy under `pages.*`, `glossary.*`, `method.*`, `gone.*` (the 410 page), `og.*` and `feed.*` (the feed's 503 body); "read next" is `pages.readNext`. Override any of it
540
+ with `blog({ messages: { pl: { pages: { readMore: "..." } } } })`. Command output and file errors are developer output, in English.
541
+
542
+ ## 10. Hooks
543
+
544
+ - `blog({ fields })`: the app's frontmatter schema (FIRE_TRACKER's calculator scenario lives here).
545
+ - `gate` of `runBlogPublish` and `runBlogCli`: `(file, article) => problems`, called only for files
546
+ going public; any problem refuses the whole run. Default in `runBlogCli`: `createQualityGate`.
547
+ - `blog({ quality: { plugins } })`: the app's domain rules. A plugin declares its rules and checks one
548
+ text at a time; it is pure (no network, no file system) and its findings take part in severity
549
+ overrides and the catalog. A throw becomes `plugin-failed`, an undeclared rule id
550
+ `plugin-rule-undeclared`.
551
+
552
+ ```ts
553
+ import type { QualityPlugin } from "@softure-ai/blog/server";
554
+
555
+ export const tickerPlugin: QualityPlugin = {
556
+ name: "tickers",
557
+ rules: [{ id: "ticker-format", severity: "error", description: "tickers are written in capitals" }],
558
+ check: ({ blocks }) =>
559
+ blocks
560
+ .filter((block) => /\$[a-z]{2,5}\b/.test(block.text))
561
+ .map((block) => ({ rule: "ticker-format", severity: "error", message: "write the ticker in capitals", line: block.line })),
562
+ };
563
+ ```
564
+
565
+ The context holds the parsed `article` (with the app's `fields`), the body `blocks` with file lines
566
+ (directives such as `::chart{…}` are blocks of their own), the fenced `pluginBlocks` of the block
567
+ plugins in `quality.blocks` (type, info, fence line, `requires`), `today` and the language `ruleset` (for
568
+ its number notation); the text helpers (`toProse`, `splitSentences`, `findSignificantNumbers`, …) are
569
+ 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).
572
+ - The pages' `cta` and `afterArticle` slots: the app's call to action and blocks (a waitlist form).
573
+
574
+ ## 11. GDPR
575
+
576
+ Articles hold editorial content, no personal data: nothing to export or delete.
577
+
578
+ ## 12. Limitations
579
+
580
+ - Two runs that rename one article away from a slug and give it to another at the same moment can leave the
581
+ slug both current and in the slug history (BF-12).
582
+ - The content hash is part of the contract: a field added later enters it only when present.
583
+ - No `--stdin` (a deploy transport).
584
+ - The refresh route expires the cache of the instance that answers it. With several instances and Next's
585
+ default (in-memory) cache handler, the others show a publish after `revalidateSeconds`; a shared cache
586
+ handler covers them.
587
+ - The renderer has no raw HTML and no figures: an image has no caption, and the app hosts and sizes its
588
+ 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).
590
+ - The gate reads Markdown line by line (blocks, not a syntax tree): enough for the rules, not a
591
+ 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` →
600
+ `article` and `termin` → `term`, `formy` → `forms`, `klaster` → `cluster`, `filar` → `pillar`,
601
+ `tytul` → `title`, `opis` → `description`, `w_skrocie` → `summary`, `aktualne_na` →
602
+ `current_as_of`, `opublikowano` → `published_at`, `zrodla` → `sources` with `nazwa` → `name`,
603
+ `faq` items `pytanie` → `question` and `odpowiedz` → `answer`), move `scenariusz` into the app's
604
+ `fields`, and copy the rows into `blog.articles` with the hash the package computes from the renamed
605
+ files, or the first publish marks every published text as updated.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=bin.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.d.ts","sourceRoot":"","sources":["../../src/cli/bin.ts"],"names":[],"mappings":""}
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ // The `softure-blog` executable. All logic lives in command.ts and run.ts, which the tests call directly.
3
+ import { runBlogCommand } from "./command.js";
4
+ process.exitCode = await runBlogCommand({ argv: process.argv.slice(2), cwd: process.cwd() });
5
+ //# sourceMappingURL=bin.js.map