@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
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The listing: articles as cards in groups by cluster, the pillar of a cluster as a lead card before
3
+ // the grid. A whole card is one link (its title's anchor stretches over the card in CSS), so a screen
4
+ // reader hears one link named after the text and a finger can hit anywhere.
5
+ import { formatMessage } from "@softure-ai/core";
6
+ import { formatDay, getArticleDates } from "../pages/dates.js";
7
+ import { getClusterAnchor } from "../pages/listing.js";
8
+ import { getArticlePath } from "../pages/paths.js";
9
+ import { getReadingMinutes } from "../render/reading-time.js";
10
+ import { BlogFooterNote, BlogLayout } from "./blog-layout.js";
11
+ function CardMeta({ article, context, timezone }) {
12
+ const copy = context.messages.pages;
13
+ const dates = getArticleDates(article, timezone);
14
+ const shownDay = dates.updated ?? dates.published;
15
+ const date = formatDay(shownDay, context.locale);
16
+ return (_jsxs("p", { className: "blog-card-meta", children: [_jsx("time", { dateTime: shownDay, children: dates.updated === null ? date : formatMessage(copy.updatedOn, { date }) }), _jsx("span", { "aria-hidden": "true", children: " · " }), _jsx("span", { children: formatMessage(copy.readingTime, { minutes: getReadingMinutes(article.bodyMarkdown) }) })] }));
17
+ }
18
+ function ArticleCard({ article, context, timezone, isLead }) {
19
+ return (_jsxs("article", { className: isLead ? "blog-card blog-card-lead" : "blog-card", "data-slug": article.slug, children: [isLead ? _jsx("p", { className: "blog-badge", children: context.messages.pages.startHere }) : null, _jsx("h3", { className: "blog-card-title", children: _jsx("a", { className: "blog-card-link", href: getArticlePath(context.routes, article.slug), children: article.title }) }), _jsx("p", { className: "blog-card-description", children: article.description }), _jsx(CardMeta, { article: article, context: context, timezone: timezone })] }));
20
+ }
21
+ export function BlogListingView({ context, groups, termCount, timezone, cta }) {
22
+ const copy = context.messages.pages;
23
+ return (_jsxs(BlogLayout, { context: context, title: copy.blogTitle, lead: copy.blogDescription, children: [groups.length === 0 ? (_jsx("p", { className: "blog-empty", children: copy.empty })) : (_jsx("div", { className: "blog-groups", children: groups.map((group) => {
24
+ const id = group.cluster === null ? undefined : getClusterAnchor(group.cluster);
25
+ const headingId = `${id ?? "cluster-other"}-heading`;
26
+ return (_jsxs("section", { id: id, "aria-labelledby": headingId, className: "blog-group", children: [_jsx("h2", { id: headingId, className: "blog-group-title", children: group.label }), group.lead === null ? null : _jsx(ArticleCard, { article: group.lead, context: context, timezone: timezone, isLead: true }), group.rest.length === 0 ? null : (_jsx("ul", { className: "blog-cards", children: group.rest.map((article) => (_jsx("li", { children: _jsx(ArticleCard, { article: article, context: context, timezone: timezone, isLead: false }) }, article.id))) }))] }, group.cluster ?? ""));
27
+ }) })), cta === undefined ? null : _jsx("div", { className: "blog-slot", children: cta }), termCount > 0 ? (_jsxs("p", { className: "blog-glossary-teaser", children: [_jsx("a", { href: context.routes.glossary, children: copy.glossaryLink }), ": ", copy.glossaryTeaser] })) : null, _jsx(BlogFooterNote, { context: context })] }));
28
+ }
29
+ //# sourceMappingURL=blog-listing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog-listing.js","sourceRoot":"","sources":["../../src/ui/blog-listing.tsx"],"names":[],"mappings":"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"}
@@ -0,0 +1,5 @@
1
+ import type { BlogPageContext } from "./page-context.js";
2
+ export declare function BlogMethodView({ context }: {
3
+ readonly context: BlogPageContext;
4
+ }): import("react").JSX.Element;
5
+ //# sourceMappingURL=blog-method.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog-method.d.ts","sourceRoot":"","sources":["../../src/ui/blog-method.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAUzD,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,EAAE;IAAE,QAAQ,CAAC,OAAO,EAAE,eAAe,CAAA;CAAE,+BAmBhF"}
@@ -0,0 +1,21 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // "How our texts are made": who writes, where facts come from, sources, checks and corrections. The
3
+ // copy is the module's default in `messages.method`; an app states its own method through
4
+ // `blog({ messages: { en: { method: { … } } } })`.
5
+ import { BlogFooterNote, BlogLayout } from "./blog-layout.js";
6
+ const SECTIONS = [
7
+ ["who", "whoTitle", "whoBody"],
8
+ ["facts", "factsTitle", "factsBody"],
9
+ ["sources", "sourcesTitle", "sourcesBody"],
10
+ ["checks", "checksTitle", "checksBody"],
11
+ ["corrections", "correctionsTitle", "correctionsBody"],
12
+ ];
13
+ export function BlogMethodView({ context }) {
14
+ const copy = context.messages.method;
15
+ const crumbs = [
16
+ { name: context.messages.pages.blogTitle, path: context.routes.index },
17
+ { name: copy.title, path: context.methodPath ?? context.routes.index },
18
+ ];
19
+ return (_jsx(BlogLayout, { context: context, title: copy.title, lead: copy.description, crumbs: crumbs, children: _jsxs("div", { className: "blog-article", children: [SECTIONS.map(([id, title, body]) => (_jsxs("section", { id: id, "aria-labelledby": `${id}-heading`, className: "blog-section", children: [_jsx("h2", { id: `${id}-heading`, children: copy[title] }), _jsx("p", { children: copy[body] })] }, id))), _jsx(BlogFooterNote, { context: { ...context, methodPath: null } })] }) }));
20
+ }
21
+ //# sourceMappingURL=blog-method.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"blog-method.js","sourceRoot":"","sources":["../../src/ui/blog-method.tsx"],"names":[],"mappings":";AAAA,oGAAoG;AACpG,0FAA0F;AAC1F,mDAAmD;AACnD,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAG9D,MAAM,QAAQ,GAAG;IACf,CAAC,KAAK,EAAE,UAAU,EAAE,SAAS,CAAC;IAC9B,CAAC,OAAO,EAAE,YAAY,EAAE,WAAW,CAAC;IACpC,CAAC,SAAS,EAAE,cAAc,EAAE,aAAa,CAAC;IAC1C,CAAC,QAAQ,EAAE,aAAa,EAAE,YAAY,CAAC;IACvC,CAAC,aAAa,EAAE,kBAAkB,EAAE,iBAAiB,CAAC;CAC9C,CAAC;AAEX,MAAM,UAAU,cAAc,CAAC,EAAE,OAAO,EAAyC;IAC/E,MAAM,IAAI,GAAG,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC;IACrC,MAAM,MAAM,GAAG;QACb,EAAE,IAAI,EAAE,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,EAAE,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE;QACtE,EAAE,IAAI,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,OAAO,CAAC,UAAU,IAAI,OAAO,CAAC,MAAM,CAAC,KAAK,EAAE;KACvE,CAAC;IACF,OAAO,CACL,KAAC,UAAU,IAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,KAAK,EAAE,IAAI,EAAE,IAAI,CAAC,WAAW,EAAE,MAAM,EAAE,MAAM,YACrF,eAAK,SAAS,EAAC,cAAc,aAC1B,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,KAAK,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC,CACnC,mBAAkB,EAAE,EAAE,EAAE,qBAAmB,GAAG,EAAE,UAAU,EAAE,SAAS,EAAC,cAAc,aAClF,aAAI,EAAE,EAAE,GAAG,EAAE,UAAU,YAAG,IAAI,CAAC,KAAK,CAAC,GAAM,EAC3C,sBAAI,IAAI,CAAC,IAAI,CAAC,GAAK,KAFP,EAAE,CAGN,CACX,CAAC,EACF,KAAC,cAAc,IAAC,OAAO,EAAE,EAAE,GAAG,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,GAAI,IACzD,GACK,CACd,CAAC;AACJ,CAAC"}
@@ -0,0 +1,7 @@
1
+ export { ArticleBody, ArticleDatesList, BlogArticleView, SourceList, type BlogArticleViewProps, type RenderedBody } from "./blog-article.js";
2
+ export { GlossaryIndexView, GlossaryTermView, type GlossaryIndexViewProps, type GlossaryTermViewProps } from "./blog-glossary.js";
3
+ export { BlogFooterNote, BlogLayout, Breadcrumbs, JsonLdScript, type BlogLayoutProps } from "./blog-layout.js";
4
+ export { BlogListingView, type BlogListingViewProps } from "./blog-listing.js";
5
+ export { BlogMethodView } from "./blog-method.js";
6
+ export type { BlogPageContext } from "./page-context.js";
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,eAAe,EAAE,UAAU,EAAE,KAAK,oBAAoB,EAAE,KAAK,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAC7I,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAAE,KAAK,sBAAsB,EAAE,KAAK,qBAAqB,EAAE,MAAM,oBAAoB,CAAC;AAClI,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC/G,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,mBAAmB,CAAC;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC;AAClD,YAAY,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC"}
@@ -0,0 +1,8 @@
1
+ // Server components of the blog's pages. They take ready data and copy and import nothing from Next,
2
+ // so an app can compose its own page from them; `@softure-ai/blog/next` wires them to the config.
3
+ export { ArticleBody, ArticleDatesList, BlogArticleView, SourceList } from "./blog-article.js";
4
+ export { GlossaryIndexView, GlossaryTermView } from "./blog-glossary.js";
5
+ export { BlogFooterNote, BlogLayout, Breadcrumbs, JsonLdScript } from "./blog-layout.js";
6
+ export { BlogListingView } from "./blog-listing.js";
7
+ export { BlogMethodView } from "./blog-method.js";
8
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/ui/index.ts"],"names":[],"mappings":"AAAA,qGAAqG;AACrG,kGAAkG;AAClG,OAAO,EAAE,WAAW,EAAE,gBAAgB,EAAE,eAAe,EAAE,UAAU,EAAgD,MAAM,mBAAmB,CAAC;AAC7I,OAAO,EAAE,iBAAiB,EAAE,gBAAgB,EAA2D,MAAM,oBAAoB,CAAC;AAClI,OAAO,EAAE,cAAc,EAAE,UAAU,EAAE,WAAW,EAAE,YAAY,EAAwB,MAAM,kBAAkB,CAAC;AAC/G,OAAO,EAAE,eAAe,EAA6B,MAAM,mBAAmB,CAAC;AAC/E,OAAO,EAAE,cAAc,EAAE,MAAM,kBAAkB,CAAC"}
@@ -0,0 +1,15 @@
1
+ import type { Locale } from "@softure-ai/core";
2
+ import type { BlogMessages } from "../messages/index.js";
3
+ import type { BlogRoutes } from "../pages/paths.js";
4
+ export interface BlogPageContext {
5
+ readonly messages: BlogMessages;
6
+ readonly locale: Locale;
7
+ readonly routes: BlogRoutes;
8
+ /** The method page's path; `null` when the app does not mount it (nothing links to it then). */
9
+ readonly methodPath: string | null;
10
+ /** The site's name for the signature; `null` without a brand. */
11
+ readonly brand: string | null;
12
+ /** The note under every text in the locale; `null` without one. */
13
+ readonly disclaimer: string | null;
14
+ }
15
+ //# sourceMappingURL=page-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-context.d.ts","sourceRoot":"","sources":["../../src/ui/page-context.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC;AAC/C,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,sBAAsB,CAAC;AACzD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAEpD,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,QAAQ,EAAE,YAAY,CAAC;IAChC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,gGAAgG;IAChG,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IACnC,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IAC9B,mEAAmE;IACnE,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=page-context.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-context.js","sourceRoot":"","sources":["../../src/ui/page-context.ts"],"names":[],"mappings":""}
@@ -0,0 +1,67 @@
1
+ -- Blog articles and glossary terms, a copy of the app's Markdown files (`softure-blog publish`), and
2
+ -- the history of their old slugs (301 targets). The files are the source of truth; rows change only
3
+ -- through the publish command. A withdrawn article keeps its row (the page answers 410, not 404).
4
+ -- Rollback: DROP TABLE blog.slug_history; DROP TABLE blog.articles; DROP FUNCTION blog.is_kebab(text);
5
+ -- then DELETE FROM softure.migrations WHERE module = 'blog' AND version = 1;
6
+
7
+ -- A stable key or address segment: lowercase letters and digits joined by single hyphens, at most
8
+ -- 100 characters.
9
+ CREATE FUNCTION is_kebab(value text) RETURNS boolean LANGUAGE sql IMMUTABLE AS $$
10
+ SELECT char_length(value) <= 100 AND value ~ '^[a-z0-9]+(-[a-z0-9]+)*$'
11
+ $$;
12
+
13
+ CREATE TABLE articles (
14
+ -- The file's `id`: given once and never changed, so a slug can change.
15
+ id text PRIMARY KEY CHECK (is_kebab(id)),
16
+ -- The address segment; the file is named `<slug>.md`.
17
+ slug text NOT NULL CHECK (is_kebab(slug)),
18
+ kind text NOT NULL CHECK (kind IN ('article', 'term')),
19
+ -- The topic for "read next" lists; the pillar is the main text of its cluster.
20
+ cluster text CHECK (cluster IS NULL OR is_kebab(cluster)),
21
+ is_pillar boolean NOT NULL DEFAULT false,
22
+ title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
23
+ description text NOT NULL CHECK (char_length(description) BETWEEN 1 AND 320),
24
+ -- The "in short" box above the body; NULL for none.
25
+ summary text CHECK (summary IS NULL OR char_length(summary) BETWEEN 1 AND 600),
26
+ body_markdown text NOT NULL CHECK (body_markdown <> ''),
27
+ status text NOT NULL CHECK (status IN ('draft', 'published', 'withdrawn')),
28
+ -- The day the facts in the text were checked.
29
+ current_as_of date NOT NULL,
30
+ published_at timestamptz,
31
+ -- Moves only when the content of an already published text changes.
32
+ updated_at timestamptz,
33
+ -- [{ "name": ..., "url": ... }]
34
+ sources jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(sources) = 'array'),
35
+ -- [{ "question": ..., "answer": ... }]
36
+ faq jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(faq) = 'array'),
37
+ -- The phrases that link to a term's definition (["tax wrapper", "tax wrappers"]); empty for articles.
38
+ term_forms jsonb NOT NULL DEFAULT '[]' CHECK (jsonb_typeof(term_forms) = 'array'),
39
+ -- The app's own frontmatter fields (blog({ fields })), as parsed.
40
+ fields jsonb NOT NULL DEFAULT '{}' CHECK (jsonb_typeof(fields) = 'object'),
41
+ -- sha256 (hex) of the content the reader sees; unchanged files are skipped by it.
42
+ content_sha256 text NOT NULL CHECK (content_sha256 ~ '^[0-9a-f]{64}$'),
43
+ created_at timestamptz NOT NULL,
44
+ CONSTRAINT articles_slug_key UNIQUE (slug),
45
+ CONSTRAINT articles_published_has_date CHECK (status <> 'published' OR published_at IS NOT NULL),
46
+ CONSTRAINT articles_updated_after_publication CHECK (updated_at IS NULL OR published_at IS NOT NULL),
47
+ CONSTRAINT articles_pillar_has_cluster CHECK (NOT is_pillar OR cluster IS NOT NULL),
48
+ CONSTRAINT articles_term_forms_by_kind CHECK (
49
+ (kind = 'term' AND jsonb_array_length(term_forms) > 0) OR (kind = 'article' AND jsonb_array_length(term_forms) = 0)
50
+ ),
51
+ -- One pillar per cluster among the texts that are not withdrawn. Deferred to the end of the
52
+ -- transaction, so a publish run can move the pillar from one article to another.
53
+ CONSTRAINT articles_one_pillar_per_cluster EXCLUDE USING btree (cluster WITH =)
54
+ WHERE (is_pillar AND status <> 'withdrawn') DEFERRABLE INITIALLY DEFERRED
55
+ );
56
+
57
+ -- Lists of published texts, newest first.
58
+ CREATE INDEX articles_published_idx ON articles (published_at DESC, slug) WHERE status = 'published';
59
+
60
+ CREATE TABLE slug_history (
61
+ -- A slug the article had before; it redirects to the article's current slug.
62
+ old_slug text PRIMARY KEY CHECK (is_kebab(old_slug)),
63
+ article_id text NOT NULL REFERENCES articles (id) ON DELETE CASCADE,
64
+ changed_at timestamptz NOT NULL
65
+ );
66
+
67
+ CREATE INDEX slug_history_article_idx ON slug_history (article_id);
@@ -0,0 +1,8 @@
1
+ # Migrations
2
+
3
+ Plain SQL, forward only: `NNNN_<description>.sql`, each starting with a comment that states the
4
+ rollback plan (docs/02-module-standard.md §4). The tables live in the `blog` schema.
5
+
6
+ | File | What it does |
7
+ | --- | --- |
8
+ | `0001_create_articles.sql` | `blog.articles` and `blog.slug_history` with their constraints |
package/module.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "id": "blog",
3
+ "version": "0.1.6",
4
+ "dependsOn": { "seo": "^0.1.0?", "security": "^0.1.0?" },
5
+ "dbSchema": "blog",
6
+ "tables": ["articles", "slug_history"],
7
+ "env": [
8
+ {
9
+ "name": "BLOG_REFRESH_SECRET",
10
+ "required": false,
11
+ "description": "Shared secret (at least 32 characters) of the cache refresh route refreshBlogCache, set for the running app and for softure-blog publish; without it a publish shows after revalidateSeconds."
12
+ }
13
+ ],
14
+ "switches": [],
15
+ "routes": { "index": "/blog", "glossary": "/blog/glossary", "method": "/blog/how-we-write", "rss": "/blog/rss.xml", "refresh": "/api/blog/refresh" },
16
+ "mount": [
17
+ { "kind": "page", "path": "app/blog/page.tsx", "export": "BlogIndexPage" },
18
+ { "kind": "page", "path": "app/blog/[slug]/page.tsx", "export": "BlogArticlePage" },
19
+ { "kind": "route-handler", "path": "app/blog/[slug]/opengraph-image.tsx", "export": "BlogArticleOgImage" },
20
+ { "kind": "page", "path": "app/blog/glossary/page.tsx", "export": "GlossaryIndexPage" },
21
+ { "kind": "page", "path": "app/blog/glossary/[slug]/page.tsx", "export": "GlossaryTermPage" },
22
+ { "kind": "page", "path": "app/blog/how-we-write/page.tsx", "export": "BlogMethodPage" },
23
+ { "kind": "route-handler", "path": "app/blog/rss.xml/route.ts", "export": "serveBlogRss" },
24
+ { "kind": "route-handler", "path": "app/api/blog/refresh/route.ts", "export": "refreshBlogCache" },
25
+ { "kind": "middleware", "path": "proxy.ts", "export": "createBlogRedirects" }
26
+ ],
27
+ "privacy": { "exports": false, "deletes": false }
28
+ }
package/package.json CHANGED
@@ -1,6 +1,104 @@
1
1
  {
2
2
  "name": "@softure-ai/blog",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.6",
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
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": [
8
+ "*.css"
9
+ ],
10
+ "engines": {
11
+ "node": ">=22"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/SOFTURE/AI.git",
16
+ "directory": "modules/blog"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "CHANGELOG.md",
21
+ "src",
22
+ "!src/**/*.test.ts",
23
+ "!src/**/*.test.tsx",
24
+ "migrations",
25
+ "module.json",
26
+ "skill",
27
+ "styles.css"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public",
31
+ "provenance": true
32
+ },
33
+ "bin": {
34
+ "softure-blog": "./dist/cli/bin.js"
35
+ },
36
+ "exports": {
37
+ ".": {
38
+ "@softure-ai/source": "./src/index.ts",
39
+ "types": "./dist/index.d.ts",
40
+ "default": "./dist/index.js"
41
+ },
42
+ "./server": {
43
+ "@softure-ai/source": "./src/server/index.ts",
44
+ "types": "./dist/server/index.d.ts",
45
+ "default": "./dist/server/index.js"
46
+ },
47
+ "./next": {
48
+ "@softure-ai/source": "./src/next/index.ts",
49
+ "types": "./dist/next/index.d.ts",
50
+ "default": "./dist/next/index.js"
51
+ },
52
+ "./ui": {
53
+ "@softure-ai/source": "./src/ui/index.ts",
54
+ "types": "./dist/ui/index.d.ts",
55
+ "default": "./dist/ui/index.js"
56
+ },
57
+ "./proxy": {
58
+ "@softure-ai/source": "./src/proxy/index.ts",
59
+ "types": "./dist/proxy/index.d.ts",
60
+ "default": "./dist/proxy/index.js"
61
+ },
62
+ "./cli": {
63
+ "@softure-ai/source": "./src/cli/index.ts",
64
+ "types": "./dist/cli/index.d.ts",
65
+ "default": "./dist/cli/index.js"
66
+ },
67
+ "./styles.css": "./styles.css"
68
+ },
69
+ "scripts": {
70
+ "build": "tsc -p tsconfig.build.json"
71
+ },
72
+ "dependencies": {
73
+ "@softure-ai/core": "^0.1.0",
74
+ "@softure-ai/db": "^0.1.0",
75
+ "markdown-it": "^15.0.2",
76
+ "markdown-it-footnote": "^4.0.0",
77
+ "yaml": "^2.9.1",
78
+ "zod": "^4.6.5"
79
+ },
80
+ "peerDependencies": {
81
+ "@softure-ai/security": "^0.1.0",
82
+ "@softure-ai/seo": "^0.1.0",
83
+ "@softure-ai/ui": "^0.1.0",
84
+ "drizzle-orm": "^0.45.2",
85
+ "next": "^16.0.0",
86
+ "react": "^19.0.0"
87
+ },
88
+ "devDependencies": {
89
+ "@fontsource/inter": "^5.3.0",
90
+ "@softure-ai/security": "^0.1.0",
91
+ "@softure-ai/seo": "^0.1.0",
92
+ "@softure-ai/ui": "^0.1.7",
93
+ "@types/markdown-it": "^14.2.0",
94
+ "@types/markdown-it-footnote": "^3.0.4"
95
+ },
96
+ "peerDependenciesMeta": {
97
+ "@softure-ai/security": {
98
+ "optional": true
99
+ },
100
+ "@softure-ai/seo": {
101
+ "optional": true
102
+ }
103
+ }
104
+ }
package/skill/SKILL.md ADDED
@@ -0,0 +1,72 @@
1
+ ---
2
+ name: blog-write
3
+ description: Write or revise a blog article or glossary term in {{contentDir}} so that it answers in the first paragraph, sources its numbers, reads like a person wrote it and passes `{{command}} check`. Use for every blog text and before every publish. Triggers - "write an article", "blog post", "revise the article", "refresh the numbers", "add a glossary term".
4
+ ---
5
+
6
+ <!-- Generated by `softure-blog skill install` from @softure-ai/blog and the app's blog() config. Do not edit: change the config and run the command again. -->
7
+
8
+ # blog-write: blog texts that pass the gate
9
+
10
+ Texts may reach production without a human read. The only controls are this skill and the gate,
11
+ `{{command}} check`. Search engines punish mass content without value, and AI assistants quote
12
+ texts that carry a number, a source and the answer up front.
13
+
14
+ Write in **{{languageName}}**.
15
+ {{#firstPersonSingular}}
16
+ The texts are signed by the editors: write "we" (the editors) and "you" (the reader), never "I".
17
+ {{/firstPersonSingular}}
18
+ Tone: calm and concrete, no exclamations, no selling.
19
+
20
+ ## Procedure
21
+
22
+ 1. **Topic and question.** One text answers one question. Write it down in one sentence, the way a
23
+ person would ask an AI assistant.
24
+ 2. **Facts first, text second.** Collect the numbers with their primary sources (laws, official
25
+ statistics, the issuer's own documents), each with its address. Work out one example for a
26
+ concrete case: inputs, result. Keep the inputs: they go into a footnote.
27
+ 3. **Draft by the structure** in [references/structure.md](references/structure.md): the lead with
28
+ the answer and a number, `##` sections as questions answered in their first sentence, footnotes
29
+ on numbers, internal links. The file format is in
30
+ [references/template.md](references/template.md); save the file as `{{contentDir}}/<slug>.md` with
31
+ `status: draft`.
32
+ 4. **Rewrite by the rules** in [references/rules.md](references/rules.md). Do not patch phrases one
33
+ by one: when a sentence stays stiff, rewrite the paragraph around its one thought. Never add a fact
34
+ you do not have from a source or your own calculation. `references/rules.md` lists the rules of
35
+ this app; where another file mentions a rule, it wins.
36
+ 5. **The gate:** `{{command}} check {{contentDir}}/<slug>.md`. Fix every error in the text, never in
37
+ the gate. Read every warning and fix it unless you have a reason.
38
+ 6. **The sceptical reviewer.** Start a separate agent with the prompt in
39
+ [references/reviewer.md](references/reviewer.md) and only the file, without your context and
40
+ without this skill. A total below 35/50 or any blocking item: rewrite and go back to step 5.
41
+ 7. **The gate with the network:** `{{command}} check {{contentDir}}/<slug>.md --external` from a
42
+ machine with network access. Where outbound requests are blocked, the app's scheduled links
43
+ workflow checks them.
44
+ 8. **Publish:** set `status: published`, then `{{command}} publish {{contentDir}}/<slug>.md` (a dry
45
+ run that shows the change) and the same with `--commit`. Publish runs the gate again and refuses an
46
+ error. Never work around the gate: if a rule is wrong, the fix is a separate change to the rule,
47
+ with a test.
48
+
49
+ {{#appSections}}
50
+ ## The app's own sections
51
+
52
+ `references/app.md` adds this app's own procedure: {{appSectionTitles}}. Read it
53
+ before step 2; it adds to the procedure above and never switches off a rule of the gate.
54
+
55
+ {{/appSections}}
56
+ ## Refreshing a text
57
+
58
+ When a number in a text changes at its source, fix the text, check every other number against its
59
+ source, then move `current_as_of` and publish again. A text whose `current_as_of` is older than
60
+ {{staleAfterDays}} days gets a `stale` warning.
61
+
62
+ ## What we never do
63
+
64
+ - Mass pages for every combination of inputs, texts without a number of their own, rewrites of
65
+ other sites' articles.
66
+ {{#ymyl}}
67
+ - Promises of a gain, "no risk", orders to buy a product.
68
+ {{/ymyl}}
69
+ - Keyword stuffing.
70
+
71
+ Sources of the patterns: `blader/humanizer` (MIT) and `hardikpandya/stop-slop` (MIT), by way of
72
+ FIRE_TRACKER's writing skill: reading, not a dependency.
@@ -0,0 +1,39 @@
1
+ # The sceptical reviewer: a prompt for a second agent
2
+
3
+ Start a separate agent (the Agent tool, a general-purpose agent) with the prompt below and only the
4
+ path of the file. No skill, no author's notes: it must read the way a stranger reads. Keep its answer
5
+ in your scratch notes, not in the repository.
6
+
7
+ ---
8
+
9
+ You are a sceptical reader of a {{languageName}} guide. You know the topic roughly, you have read
10
+ many texts that sound generated, and you do not trust them. Read the file `<path>` (skip the
11
+ frontmatter and footnotes when judging style, but check that the footnotes support the sentences).
12
+
13
+ Answer in this order:
14
+
15
+ 1. **The answer in 15 seconds.** What is the text's question, and did you get the answer, with a
16
+ number, in the first two paragraphs? Quote the sentence.
17
+ 2. **Sentences that sound like AI:** literal quotes (at most 10), each with one word for what gives
18
+ it away (generality, moral, staging, rhythm, inflation, explaining the obvious, borrowed
19
+ authority).
20
+ 3. **Claims without cover:** sentences that state a fact or a number that no footnote supports.
21
+ 4. **What is here that is nowhere else?** Does the text have its own number (a worked example for a
22
+ concrete case), or does it summarize what every other site says?
23
+ 5. **Risk for the reader:** does anything read as advice to buy, a promise, or play down a risk?
24
+ 6. **Scores 1 to 10** on five axes: directness (states or announces), rhythm (varied or a
25
+ metronome), trust in the reader, authenticity (sounds like a person), density (can anything be
26
+ cut). Total out of 50.
27
+ 7. **Blocking:** what must be fixed before the text goes out. Empty only when nothing really blocks.
28
+
29
+ Do not rewrite the text. Do not praise. Be concrete: quote, problem, why.
30
+
31
+ ---
32
+
33
+ ## How to use the answer
34
+
35
+ - A total below **35/50**, or a non-empty blocking list: rewrite, then run the reviewer again (a new
36
+ agent, not the same one).
37
+ - An objection under point 3 or 5 always blocks, whatever the score.
38
+ - An objection that comes back text after text is a candidate for a new rule of the gate (a separate
39
+ change with a test, through the developers).
@@ -0,0 +1,122 @@
1
+ # The rules of the gate
2
+
3
+ These are the rules `{{command}} check` enforces in this app, with the severity the app gave them.
4
+ An **error** stops the publish at one hit. A warning is reported once per text with a count: fix it
5
+ unless you have a reason. The gate reads {{languageName}} texts; its message names what it found.
6
+
7
+ The principle behind the style rules: a model writes for the widest possible reader, so it writes
8
+ in generalities. A person writes for one reader about one matter, concretely and unevenly. Every
9
+ sentence that stays adds something the reader did not have yet. When a sentence trips a rule,
10
+ rewrite the paragraph around its one thought; do not swap a word for a synonym.
11
+
12
+ ## File and structure
13
+
14
+ | Rule | Severity | What the gate looks for | What to write instead |
15
+ | --- | --- | --- | --- |
16
+ | `file` | | | Fix the frontmatter as the message says; the template is in [template.md](template.md). |
17
+ | `title-length` | | | A title up to {{titleChars}} characters, with the question or the number. |
18
+ | `description-length` | | | A description of {{descriptionCharsMin}} to {{descriptionCharsMax}} characters that holds the answer. |
19
+ | `as-of-future` | | | `current_as_of` is the day you checked the facts, never a later one. |
20
+ | `stale` | | | Check every number against its source, then move `current_as_of` (older than {{staleAfterDays}} days warns). |
21
+ | `summary-missing` | | | Add `summary`: a few sentences with the answer and its number, quotable on their own. |
22
+ | `lead` | | | Open with a paragraph that answers the title's question, not with a heading, a list or a quote. |
23
+ | `lead-length` | | | Keep the first paragraph to {{leadWordsArticle}} words in an article, {{leadWordsTerm}} in a term. |
24
+ | `lead-number` | | | Put a concrete number, with its footnote, in the first two paragraphs. |
25
+ | `heading-h1` | | | No `#` heading in the body: the page takes its title from `title`. |
26
+ | `heading-order` | | | Go `##` then `###`; never skip a level. |
27
+ | `sections` | | | At least two `##` sections in an article. |
28
+ | `section-question` | | | Phrase at least one `##` heading as the question a reader asks an assistant. |
29
+ | `section-answer` | | | Under a question heading, a paragraph whose first sentence answers it. |
30
+ | `section-answer-length` | | | Keep the answer paragraph to {{answerWords}} words. |
31
+ | `length` | | | An article of {{articleWordsMin}} to {{articleWordsMax}} words, a term of {{termWordsMin}} to {{termWordsMax}}. |
32
+ | `footnote-undefined` | | | Define every `[^id]` at the end of the file. |
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. |
35
+
36
+ ## Links
37
+
38
+ | Rule | Severity | What the gate looks for | What to write instead |
39
+ | --- | --- | --- | --- |
40
+ | `internal-links` | | | At least {{internalLinksArticle}} internal links in an article ({{internalLinksTerm}} in a term): related texts under `{{articlesPath}}/`, glossary terms under `{{termsPath}}/`, the app's pages. |
41
+ | `internal-link-target` | | | Link only to pages, texts and terms that exist (a draft does not count as a target). |
42
+ | `external-link-https` | | | Use the `https://` address of the source. |
43
+ | `external-link-dead` | | | Replace a link that does not answer with the source's current address (checked only with `--external`). |
44
+ | `term-form-conflict` | | | Give each phrase to one term: remove a form from every other term's `forms`, or link the other term by hand where it is meant. |
45
+
46
+ ## Images
47
+
48
+ | Rule | Severity | What the gate looks for | What to write instead |
49
+ | --- | --- | --- | --- |
50
+ | `image-source` | | | Use an image from the site's own paths or an allowed host; without an image policy the app shows no images, so leave them out. |
51
+ | `image-alt` | | | Give every image an alt text that says what it shows (`![Monthly costs by category](/images/costs.png)`). |
52
+ | `image-dimensions` | | | Use only images the app knows the size of; ask for a new one to be added rather than linking a file. |
53
+
54
+ ## Style
55
+
56
+ | Rule | Severity | What the gate looks for | What to write instead |
57
+ | --- | --- | --- | --- |
58
+ | `announcement` | | | State the fact itself: not "it is worth noting that the limit grows", but "the limit grows". |
59
+ | `these-days` | | | Start with the number or the answer, not with "these times". |
60
+ | `not-only-but-also` | | | Name both things plainly: "cheap and simple". |
61
+ | `not-x-but-y` | | | Say Y right away; the contrast with X nobody claimed is a template. |
62
+ | `meta-commentary` | | | Remove "in this article", "let us look at", "as mentioned": the text moves forward by itself. |
63
+ | `throat-clearing` | | | Cut the run-up ("the truth is", "interestingly") and keep the claim. |
64
+ | `empty-conclusion` | | | End with the last concrete fact, without "in summary" and a repeat. |
65
+ | `crucial` | | | Name what depends on the thing instead of calling it crucial. |
66
+ | `plays-a-role` | | | Write what the thing does. |
67
+ | `puffery` | | | Replace the inflated phrase ("comprehensive", "game changer", "it depends on many factors") with a fact or a conditional answer with a number. |
68
+ | `chatbot-phrases` | | | Remove leftovers of a chat ("I hope this helps", "great question"). |
69
+ | `emoji` | | | Remove it. |
70
+ | `filler-words` | | | Check whether the sentence says the same without the word; usually it does. |
71
+ | `exclamation` | | | Say it calmly, with a full stop. |
72
+ | `straight-quotes` | | | Use the language's own quotation marks. |
73
+ | `title-case-heading` | | | Capitalize only the first word of a heading (and proper names). |
74
+ | `dashes` | | | At most one dash per {{wordsPerDash}} words: use a full stop, a comma, a colon or brackets. |
75
+ | `dashes-paragraph` | | | At most one dash per paragraph. |
76
+ | `bold-density` | | | Bold at most one phrase per {{wordsPerBold}} words; let the sentence carry the weight. |
77
+ | `bold-labels` | | | Write prose, not lists of "**Label:** text" items. |
78
+ | `triads` | | | Enumerate two things, or four; lists of three in a row read as a template. |
79
+ | `long-sentences` | | | Split sentences longer than {{sentenceWords}} words. |
80
+ | `monotone-rhythm` | | | Mix short and long sentences. |
81
+ | `repeated-openings` | | | Do not start three sentences in a row with the same word. |
82
+
83
+ ## Voice
84
+
85
+ | Rule | Severity | What the gate looks for | What to write instead |
86
+ | --- | --- | --- | --- |
87
+ | `first-person-singular` | | | Write "we" (the editors) and "you" (the reader), never "I". |
88
+ {{#ymyl}}
89
+
90
+ ## YMYL: sources and claims
91
+
92
+ | Rule | Severity | What the gate looks for | What to write instead |
93
+ | --- | --- | --- | --- |
94
+ | `sources-missing` | | | List every source in `sources` (name and address). |
95
+ | `source-https` | | | Give every source its `https://` address. |
96
+ | `number-source` | | | Put a footnote on every paragraph, list item or table with an amount, a percentage or a number from 1000 up. |
97
+ | `footnote-source` | | | Give every footnote a source address{{#ownCalculationMark}} or the phrase "{{ownCalculationMark}}" with its inputs{{/ownCalculationMark}}. |
98
+ | `footnote-not-in-sources` | | | Add every address a footnote cites to `sources`. |
99
+ | `profit-promise` | | | Describe the risk and the condition; never promise a gain or tell the reader to buy. |
100
+ {{/ymyl}}
101
+ {{#plugins}}
102
+
103
+ ## Plugins
104
+
105
+ | Rule | Severity | What the gate looks for | What to write instead |
106
+ | --- | --- | --- | --- |
107
+ | `plugin-failed` | | | A plugin of the app crashed on the text: report it to the developers, do not work around it. |
108
+ | `plugin-rule-undeclared` | | | A plugin reported a rule it does not declare: report it to the developers. |
109
+ {{/plugins}}
110
+
111
+ ## The app's own rules
112
+
113
+ {{appRules}}
114
+
115
+ ## What the gate cannot catch: check it yourself and ask the reviewer
116
+
117
+ - A one-sentence moral after every paragraph, golden lines written to be quoted.
118
+ - An argument with nobody ("many people think…"), borrowed authority ("experts say").
119
+ - Explaining the obvious to a reader who came for the number.
120
+ - Vague links ("is related to") instead of naming the relation.
121
+ - Participle tails (", highlighting the importance of…", ", which shows…").
122
+ - Avoiding "is" and "has": "serves as", "constitutes", "offers".