@softure-ai/blog 0.0.0-stage → 0.1.5

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 (418) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +602 -2
  3. package/dist/cli/bin.d.ts +3 -0
  4. package/dist/cli/bin.d.ts.map +1 -0
  5. package/dist/cli/bin.js +5 -0
  6. package/dist/cli/bin.js.map +1 -0
  7. package/dist/cli/command.d.ts +9 -0
  8. package/dist/cli/command.d.ts.map +1 -0
  9. package/dist/cli/command.js +40 -0
  10. package/dist/cli/command.js.map +1 -0
  11. package/dist/cli/index.d.ts +4 -0
  12. package/dist/cli/index.d.ts.map +1 -0
  13. package/dist/cli/index.js +5 -0
  14. package/dist/cli/index.js.map +1 -0
  15. package/dist/cli/run.d.ts +62 -0
  16. package/dist/cli/run.d.ts.map +1 -0
  17. package/dist/cli/run.js +552 -0
  18. package/dist/cli/run.js.map +1 -0
  19. package/dist/cli/skill.d.ts +47 -0
  20. package/dist/cli/skill.d.ts.map +1 -0
  21. package/dist/cli/skill.js +221 -0
  22. package/dist/cli/skill.js.map +1 -0
  23. package/dist/content/article-file.d.ts +27 -0
  24. package/dist/content/article-file.d.ts.map +1 -0
  25. package/dist/content/article-file.js +179 -0
  26. package/dist/content/article-file.js.map +1 -0
  27. package/dist/contract.d.ts +77 -0
  28. package/dist/contract.d.ts.map +1 -0
  29. package/dist/contract.js +5 -0
  30. package/dist/contract.js.map +1 -0
  31. package/dist/db/articles.d.ts +30 -0
  32. package/dist/db/articles.d.ts.map +1 -0
  33. package/dist/db/articles.js +138 -0
  34. package/dist/db/articles.js.map +1 -0
  35. package/dist/db/publish-run.d.ts +50 -0
  36. package/dist/db/publish-run.d.ts.map +1 -0
  37. package/dist/db/publish-run.js +168 -0
  38. package/dist/db/publish-run.js.map +1 -0
  39. package/dist/db/schema.d.ts +403 -0
  40. package/dist/db/schema.d.ts.map +1 -0
  41. package/dist/db/schema.js +33 -0
  42. package/dist/db/schema.js.map +1 -0
  43. package/dist/discovery/dates.d.ts +9 -0
  44. package/dist/discovery/dates.d.ts.map +1 -0
  45. package/dist/discovery/dates.js +19 -0
  46. package/dist/discovery/dates.js.map +1 -0
  47. package/dist/discovery/index.d.ts +8 -0
  48. package/dist/discovery/index.d.ts.map +1 -0
  49. package/dist/discovery/index.js +11 -0
  50. package/dist/discovery/index.js.map +1 -0
  51. package/dist/discovery/indexnow.d.ts +11 -0
  52. package/dist/discovery/indexnow.d.ts.map +1 -0
  53. package/dist/discovery/indexnow.js +23 -0
  54. package/dist/discovery/indexnow.js.map +1 -0
  55. package/dist/discovery/refresh.d.ts +49 -0
  56. package/dist/discovery/refresh.d.ts.map +1 -0
  57. package/dist/discovery/refresh.js +55 -0
  58. package/dist/discovery/refresh.js.map +1 -0
  59. package/dist/discovery/related.d.ts +17 -0
  60. package/dist/discovery/related.d.ts.map +1 -0
  61. package/dist/discovery/related.js +28 -0
  62. package/dist/discovery/related.js.map +1 -0
  63. package/dist/discovery/rss.d.ts +30 -0
  64. package/dist/discovery/rss.d.ts.map +1 -0
  65. package/dist/discovery/rss.js +48 -0
  66. package/dist/discovery/rss.js.map +1 -0
  67. package/dist/discovery/sitemap.d.ts +25 -0
  68. package/dist/discovery/sitemap.d.ts.map +1 -0
  69. package/dist/discovery/sitemap.js +31 -0
  70. package/dist/discovery/sitemap.js.map +1 -0
  71. package/dist/discovery/submit.d.ts +44 -0
  72. package/dist/discovery/submit.d.ts.map +1 -0
  73. package/dist/discovery/submit.js +46 -0
  74. package/dist/discovery/submit.js.map +1 -0
  75. package/dist/index.d.ts +371 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +69 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/messages/en.d.ts +83 -0
  80. package/dist/messages/en.d.ts.map +1 -0
  81. package/dist/messages/en.js +83 -0
  82. package/dist/messages/en.js.map +1 -0
  83. package/dist/messages/index.d.ts +158 -0
  84. package/dist/messages/index.d.ts.map +1 -0
  85. package/dist/messages/index.js +5 -0
  86. package/dist/messages/index.js.map +1 -0
  87. package/dist/messages/pl.d.ts +78 -0
  88. package/dist/messages/pl.d.ts.map +1 -0
  89. package/dist/messages/pl.js +78 -0
  90. package/dist/messages/pl.js.map +1 -0
  91. package/dist/next/context.d.ts +6 -0
  92. package/dist/next/context.d.ts.map +1 -0
  93. package/dist/next/context.js +27 -0
  94. package/dist/next/context.js.map +1 -0
  95. package/dist/next/data.d.ts +11 -0
  96. package/dist/next/data.d.ts.map +1 -0
  97. package/dist/next/data.js +40 -0
  98. package/dist/next/data.js.map +1 -0
  99. package/dist/next/discovery.d.ts +6 -0
  100. package/dist/next/discovery.d.ts.map +1 -0
  101. package/dist/next/discovery.js +49 -0
  102. package/dist/next/discovery.js.map +1 -0
  103. package/dist/next/index.d.ts +9 -0
  104. package/dist/next/index.d.ts.map +1 -0
  105. package/dist/next/index.js +10 -0
  106. package/dist/next/index.js.map +1 -0
  107. package/dist/next/og-fonts.d.ts +6 -0
  108. package/dist/next/og-fonts.d.ts.map +1 -0
  109. package/dist/next/og-fonts.js +8 -0
  110. package/dist/next/og-fonts.js.map +1 -0
  111. package/dist/next/og-image.d.ts +36 -0
  112. package/dist/next/og-image.d.ts.map +1 -0
  113. package/dist/next/og-image.js +77 -0
  114. package/dist/next/og-image.js.map +1 -0
  115. package/dist/next/pages.d.ts +45 -0
  116. package/dist/next/pages.d.ts.map +1 -0
  117. package/dist/next/pages.js +189 -0
  118. package/dist/next/pages.js.map +1 -0
  119. package/dist/next/refresh.d.ts +9 -0
  120. package/dist/next/refresh.d.ts.map +1 -0
  121. package/dist/next/refresh.js +81 -0
  122. package/dist/next/refresh.js.map +1 -0
  123. package/dist/options.d.ts +320 -0
  124. package/dist/options.d.ts.map +1 -0
  125. package/dist/options.js +184 -0
  126. package/dist/options.js.map +1 -0
  127. package/dist/pages/body.d.ts +18 -0
  128. package/dist/pages/body.d.ts.map +1 -0
  129. package/dist/pages/body.js +21 -0
  130. package/dist/pages/body.js.map +1 -0
  131. package/dist/pages/dates.d.ts +21 -0
  132. package/dist/pages/dates.d.ts.map +1 -0
  133. package/dist/pages/dates.js +24 -0
  134. package/dist/pages/dates.js.map +1 -0
  135. package/dist/pages/index.d.ts +7 -0
  136. package/dist/pages/index.d.ts.map +1 -0
  137. package/dist/pages/index.js +9 -0
  138. package/dist/pages/index.js.map +1 -0
  139. package/dist/pages/json-ld.d.ts +33 -0
  140. package/dist/pages/json-ld.d.ts.map +1 -0
  141. package/dist/pages/json-ld.js +95 -0
  142. package/dist/pages/json-ld.js.map +1 -0
  143. package/dist/pages/listing.d.ts +46 -0
  144. package/dist/pages/listing.d.ts.map +1 -0
  145. package/dist/pages/listing.js +57 -0
  146. package/dist/pages/listing.js.map +1 -0
  147. package/dist/pages/paths.d.ts +37 -0
  148. package/dist/pages/paths.d.ts.map +1 -0
  149. package/dist/pages/paths.js +61 -0
  150. package/dist/pages/paths.js.map +1 -0
  151. package/dist/pages/redirects.d.ts +57 -0
  152. package/dist/pages/redirects.d.ts.map +1 -0
  153. package/dist/pages/redirects.js +78 -0
  154. package/dist/pages/redirects.js.map +1 -0
  155. package/dist/proxy/index.d.ts +15 -0
  156. package/dist/proxy/index.d.ts.map +1 -0
  157. package/dist/proxy/index.js +60 -0
  158. package/dist/proxy/index.js.map +1 -0
  159. package/dist/quality/blocks.d.ts +21 -0
  160. package/dist/quality/blocks.d.ts.map +1 -0
  161. package/dist/quality/blocks.js +113 -0
  162. package/dist/quality/blocks.js.map +1 -0
  163. package/dist/quality/catalog.d.ts +10 -0
  164. package/dist/quality/catalog.d.ts.map +1 -0
  165. package/dist/quality/catalog.js +71 -0
  166. package/dist/quality/catalog.js.map +1 -0
  167. package/dist/quality/check-article.d.ts +34 -0
  168. package/dist/quality/check-article.d.ts.map +1 -0
  169. package/dist/quality/check-article.js +74 -0
  170. package/dist/quality/check-article.js.map +1 -0
  171. package/dist/quality/check-files.d.ts +23 -0
  172. package/dist/quality/check-files.d.ts.map +1 -0
  173. package/dist/quality/check-files.js +44 -0
  174. package/dist/quality/check-files.js.map +1 -0
  175. package/dist/quality/external-links.d.ts +19 -0
  176. package/dist/quality/external-links.d.ts.map +1 -0
  177. package/dist/quality/external-links.js +30 -0
  178. package/dist/quality/external-links.js.map +1 -0
  179. package/dist/quality/finding.d.ts +17 -0
  180. package/dist/quality/finding.d.ts.map +1 -0
  181. package/dist/quality/finding.js +15 -0
  182. package/dist/quality/finding.js.map +1 -0
  183. package/dist/quality/gate.d.ts +5 -0
  184. package/dist/quality/gate.d.ts.map +1 -0
  185. package/dist/quality/gate.js +10 -0
  186. package/dist/quality/gate.js.map +1 -0
  187. package/dist/quality/index.d.ts +15 -0
  188. package/dist/quality/index.d.ts.map +1 -0
  189. package/dist/quality/index.js +16 -0
  190. package/dist/quality/index.js.map +1 -0
  191. package/dist/quality/link-targets.d.ts +44 -0
  192. package/dist/quality/link-targets.d.ts.map +1 -0
  193. package/dist/quality/link-targets.js +91 -0
  194. package/dist/quality/link-targets.js.map +1 -0
  195. package/dist/quality/options.d.ts +113 -0
  196. package/dist/quality/options.d.ts.map +1 -0
  197. package/dist/quality/options.js +93 -0
  198. package/dist/quality/options.js.map +1 -0
  199. package/dist/quality/plugin.d.ts +34 -0
  200. package/dist/quality/plugin.d.ts.map +1 -0
  201. package/dist/quality/plugin.js +7 -0
  202. package/dist/quality/plugin.js.map +1 -0
  203. package/dist/quality/rules/blocks.d.ts +5 -0
  204. package/dist/quality/rules/blocks.d.ts.map +1 -0
  205. package/dist/quality/rules/blocks.js +35 -0
  206. package/dist/quality/rules/blocks.js.map +1 -0
  207. package/dist/quality/rules/images.d.ts +4 -0
  208. package/dist/quality/rules/images.d.ts.map +1 -0
  209. package/dist/quality/rules/images.js +33 -0
  210. package/dist/quality/rules/images.js.map +1 -0
  211. package/dist/quality/rules/input.d.ts +11 -0
  212. package/dist/quality/rules/input.d.ts.map +1 -0
  213. package/dist/quality/rules/input.js +2 -0
  214. package/dist/quality/rules/input.js.map +1 -0
  215. package/dist/quality/rules/links.d.ts +17 -0
  216. package/dist/quality/rules/links.d.ts.map +1 -0
  217. package/dist/quality/rules/links.js +46 -0
  218. package/dist/quality/rules/links.js.map +1 -0
  219. package/dist/quality/rules/structure.d.ts +13 -0
  220. package/dist/quality/rules/structure.d.ts.map +1 -0
  221. package/dist/quality/rules/structure.js +139 -0
  222. package/dist/quality/rules/structure.js.map +1 -0
  223. package/dist/quality/rules/style.d.ts +11 -0
  224. package/dist/quality/rules/style.d.ts.map +1 -0
  225. package/dist/quality/rules/style.js +129 -0
  226. package/dist/quality/rules/style.js.map +1 -0
  227. package/dist/quality/rules/ymyl.d.ts +5 -0
  228. package/dist/quality/rules/ymyl.d.ts.map +1 -0
  229. package/dist/quality/rules/ymyl.js +51 -0
  230. package/dist/quality/rules/ymyl.js.map +1 -0
  231. package/dist/quality/rulesets/en/ruleset.d.ts +3 -0
  232. package/dist/quality/rulesets/en/ruleset.d.ts.map +1 -0
  233. package/dist/quality/rulesets/en/ruleset.js +106 -0
  234. package/dist/quality/rulesets/en/ruleset.js.map +1 -0
  235. package/dist/quality/rulesets/index.d.ts +7 -0
  236. package/dist/quality/rulesets/index.d.ts.map +1 -0
  237. package/dist/quality/rulesets/index.js +7 -0
  238. package/dist/quality/rulesets/index.js.map +1 -0
  239. package/dist/quality/rulesets/pl/ruleset.d.ts +3 -0
  240. package/dist/quality/rulesets/pl/ruleset.d.ts.map +1 -0
  241. package/dist/quality/rulesets/pl/ruleset.js +114 -0
  242. package/dist/quality/rulesets/pl/ruleset.js.map +1 -0
  243. package/dist/quality/rulesets/types.d.ts +28 -0
  244. package/dist/quality/rulesets/types.d.ts.map +1 -0
  245. package/dist/quality/rulesets/types.js +2 -0
  246. package/dist/quality/rulesets/types.js.map +1 -0
  247. package/dist/quality/settings.d.ts +26 -0
  248. package/dist/quality/settings.d.ts.map +1 -0
  249. package/dist/quality/settings.js +32 -0
  250. package/dist/quality/settings.js.map +1 -0
  251. package/dist/quality/text.d.ts +43 -0
  252. package/dist/quality/text.d.ts.map +1 -0
  253. package/dist/quality/text.js +85 -0
  254. package/dist/quality/text.js.map +1 -0
  255. package/dist/render/glossary.d.ts +27 -0
  256. package/dist/render/glossary.d.ts.map +1 -0
  257. package/dist/render/glossary.js +71 -0
  258. package/dist/render/glossary.js.map +1 -0
  259. package/dist/render/images.d.ts +42 -0
  260. package/dist/render/images.d.ts.map +1 -0
  261. package/dist/render/images.js +84 -0
  262. package/dist/render/images.js.map +1 -0
  263. package/dist/render/index.d.ts +6 -0
  264. package/dist/render/index.d.ts.map +1 -0
  265. package/dist/render/index.js +8 -0
  266. package/dist/render/index.js.map +1 -0
  267. package/dist/render/reading-time.d.ts +8 -0
  268. package/dist/render/reading-time.d.ts.map +1 -0
  269. package/dist/render/reading-time.js +17 -0
  270. package/dist/render/reading-time.js.map +1 -0
  271. package/dist/render/render-article.d.ts +97 -0
  272. package/dist/render/render-article.d.ts.map +1 -0
  273. package/dist/render/render-article.js +373 -0
  274. package/dist/render/render-article.js.map +1 -0
  275. package/dist/render/slugify-heading.d.ts +2 -0
  276. package/dist/render/slugify-heading.d.ts.map +1 -0
  277. package/dist/render/slugify-heading.js +21 -0
  278. package/dist/render/slugify-heading.js.map +1 -0
  279. package/dist/server/health.d.ts +3 -0
  280. package/dist/server/health.d.ts.map +1 -0
  281. package/dist/server/health.js +11 -0
  282. package/dist/server/health.js.map +1 -0
  283. package/dist/server/index.d.ts +10 -0
  284. package/dist/server/index.d.ts.map +1 -0
  285. package/dist/server/index.js +13 -0
  286. package/dist/server/index.js.map +1 -0
  287. package/dist/server/og-fonts.d.ts +24 -0
  288. package/dist/server/og-fonts.d.ts.map +1 -0
  289. package/dist/server/og-fonts.js +85 -0
  290. package/dist/server/og-fonts.js.map +1 -0
  291. package/dist/server/options.d.ts +17 -0
  292. package/dist/server/options.d.ts.map +1 -0
  293. package/dist/server/options.js +56 -0
  294. package/dist/server/options.js.map +1 -0
  295. package/dist/sitemap.d.ts +11 -0
  296. package/dist/sitemap.d.ts.map +1 -0
  297. package/dist/sitemap.js +35 -0
  298. package/dist/sitemap.js.map +1 -0
  299. package/dist/ui/blog-article.d.ts +49 -0
  300. package/dist/ui/blog-article.d.ts.map +1 -0
  301. package/dist/ui/blog-article.js +45 -0
  302. package/dist/ui/blog-article.js.map +1 -0
  303. package/dist/ui/blog-glossary.d.ts +27 -0
  304. package/dist/ui/blog-glossary.d.ts.map +1 -0
  305. package/dist/ui/blog-glossary.js +20 -0
  306. package/dist/ui/blog-glossary.js.map +1 -0
  307. package/dist/ui/blog-layout.d.ts +31 -0
  308. package/dist/ui/blog-layout.d.ts.map +1 -0
  309. package/dist/ui/blog-layout.js +25 -0
  310. package/dist/ui/blog-layout.js.map +1 -0
  311. package/dist/ui/blog-listing.d.ts +15 -0
  312. package/dist/ui/blog-listing.d.ts.map +1 -0
  313. package/dist/ui/blog-listing.js +29 -0
  314. package/dist/ui/blog-listing.js.map +1 -0
  315. package/dist/ui/blog-method.d.ts +5 -0
  316. package/dist/ui/blog-method.d.ts.map +1 -0
  317. package/dist/ui/blog-method.js +21 -0
  318. package/dist/ui/blog-method.js.map +1 -0
  319. package/dist/ui/index.d.ts +7 -0
  320. package/dist/ui/index.d.ts.map +1 -0
  321. package/dist/ui/index.js +8 -0
  322. package/dist/ui/index.js.map +1 -0
  323. package/dist/ui/page-context.d.ts +15 -0
  324. package/dist/ui/page-context.d.ts.map +1 -0
  325. package/dist/ui/page-context.js +2 -0
  326. package/dist/ui/page-context.js.map +1 -0
  327. package/migrations/0001_create_articles.sql +67 -0
  328. package/migrations/README.md +8 -0
  329. package/module.json +28 -0
  330. package/package.json +98 -4
  331. package/skill/SKILL.md +72 -0
  332. package/skill/references/reviewer.md +39 -0
  333. package/skill/references/rules.md +122 -0
  334. package/skill/references/structure.md +69 -0
  335. package/skill/references/template.md +86 -0
  336. package/src/cli/bin.ts +5 -0
  337. package/src/cli/command.ts +49 -0
  338. package/src/cli/index.ts +20 -0
  339. package/src/cli/run.ts +589 -0
  340. package/src/cli/skill.ts +242 -0
  341. package/src/content/article-file.ts +184 -0
  342. package/src/contract.ts +88 -0
  343. package/src/db/articles.ts +160 -0
  344. package/src/db/publish-run.ts +224 -0
  345. package/src/db/schema.ts +36 -0
  346. package/src/discovery/dates.ts +27 -0
  347. package/src/discovery/index.ts +18 -0
  348. package/src/discovery/indexnow.ts +25 -0
  349. package/src/discovery/refresh.ts +79 -0
  350. package/src/discovery/related.ts +35 -0
  351. package/src/discovery/rss.ts +77 -0
  352. package/src/discovery/sitemap.ts +59 -0
  353. package/src/discovery/submit.ts +69 -0
  354. package/src/index.ts +99 -0
  355. package/src/messages/en.ts +82 -0
  356. package/src/messages/index.ts +7 -0
  357. package/src/messages/pl.ts +77 -0
  358. package/src/next/context.ts +30 -0
  359. package/src/next/data.ts +55 -0
  360. package/src/next/discovery.ts +49 -0
  361. package/src/next/index.ts +25 -0
  362. package/src/next/next-modules.d.ts +15 -0
  363. package/src/next/og-fonts.ts +14 -0
  364. package/src/next/og-image.tsx +108 -0
  365. package/src/next/pages.tsx +251 -0
  366. package/src/next/refresh.ts +85 -0
  367. package/src/options.ts +224 -0
  368. package/src/pages/body.ts +38 -0
  369. package/src/pages/dates.ts +39 -0
  370. package/src/pages/index.ts +37 -0
  371. package/src/pages/json-ld.ts +120 -0
  372. package/src/pages/listing.ts +95 -0
  373. package/src/pages/paths.ts +84 -0
  374. package/src/pages/redirects.ts +120 -0
  375. package/src/proxy/index.ts +66 -0
  376. package/src/quality/blocks.ts +130 -0
  377. package/src/quality/catalog.ts +86 -0
  378. package/src/quality/check-article.ts +112 -0
  379. package/src/quality/check-files.ts +63 -0
  380. package/src/quality/external-links.ts +37 -0
  381. package/src/quality/finding.ts +29 -0
  382. package/src/quality/gate.ts +15 -0
  383. package/src/quality/index.ts +28 -0
  384. package/src/quality/link-targets.ts +107 -0
  385. package/src/quality/options.ts +105 -0
  386. package/src/quality/plugin.ts +43 -0
  387. package/src/quality/rules/blocks.ts +43 -0
  388. package/src/quality/rules/images.ts +34 -0
  389. package/src/quality/rules/input.ts +12 -0
  390. package/src/quality/rules/links.ts +56 -0
  391. package/src/quality/rules/structure.ts +137 -0
  392. package/src/quality/rules/style.ts +137 -0
  393. package/src/quality/rules/ymyl.ts +57 -0
  394. package/src/quality/rulesets/en/ruleset.ts +117 -0
  395. package/src/quality/rulesets/index.ts +9 -0
  396. package/src/quality/rulesets/pl/ruleset.ts +126 -0
  397. package/src/quality/rulesets/types.ts +31 -0
  398. package/src/quality/settings.ts +60 -0
  399. package/src/quality/text.ts +113 -0
  400. package/src/render/glossary.ts +103 -0
  401. package/src/render/images.ts +110 -0
  402. package/src/render/index.ts +29 -0
  403. package/src/render/reading-time.ts +18 -0
  404. package/src/render/render-article.ts +487 -0
  405. package/src/render/slugify-heading.ts +22 -0
  406. package/src/server/health.ts +12 -0
  407. package/src/server/index.ts +28 -0
  408. package/src/server/og-fonts.ts +102 -0
  409. package/src/server/options.ts +62 -0
  410. package/src/sitemap.ts +36 -0
  411. package/src/ui/blog-article.tsx +186 -0
  412. package/src/ui/blog-glossary.tsx +104 -0
  413. package/src/ui/blog-layout.tsx +89 -0
  414. package/src/ui/blog-listing.tsx +95 -0
  415. package/src/ui/blog-method.tsx +34 -0
  416. package/src/ui/index.ts +8 -0
  417. package/src/ui/page-context.ts +17 -0
  418. package/styles.css +498 -0
@@ -0,0 +1,62 @@
1
+ // The blog options, routes and copy of the running app, read from the configuration.
2
+ import { getModule, getSiteUrls, type AnySoftureModule, type SoftureConfig } from "@softure-ai/core";
3
+ import type { BlogMessages } from "../messages/index.js";
4
+ import type { BlogOptions } from "../options.js";
5
+ import { getReservedSlugs, normalizeRoute, type BlogRoutes } from "../pages/paths.js";
6
+ import { resolveQualitySettings, type QualitySettings } from "../quality/settings.js";
7
+
8
+ const MODULE_ID = "blog";
9
+
10
+ /** The enabled module. Throws when the app did not enable it: calling its functions then is a bug. */
11
+ function getBlogModule(config: SoftureConfig): AnySoftureModule {
12
+ const module = getModule(config, MODULE_ID);
13
+ if (module === undefined) {
14
+ throw new Error("@softure-ai/blog: the module is not enabled; add blog() to modules in softure.config.ts");
15
+ }
16
+ return module;
17
+ }
18
+
19
+ export function getBlogOptions(config: SoftureConfig): BlogOptions {
20
+ // The module factory parsed these options with blogOptionsSchema.
21
+ return getBlogModule(config).options as BlogOptions;
22
+ }
23
+
24
+ /** The module's copy in the app's locale, with the app's overrides applied. */
25
+ export function getBlogMessages(config: SoftureConfig): BlogMessages {
26
+ // The module factory merged the dictionaries; their shape is the module's own.
27
+ return getBlogModule(config).messages[config.locale] as BlogMessages;
28
+ }
29
+
30
+ /** The module's routes with the app's overrides applied, without trailing slashes. */
31
+ export function getBlogRoutes(config: SoftureConfig): BlogRoutes {
32
+ const routes = getBlogModule(config).routes;
33
+ const read = (name: keyof BlogRoutes): string => {
34
+ const path = routes[name];
35
+ // The manifest declares every route, so a missing one means a broken module definition.
36
+ if (path === undefined) throw new Error(`@softure-ai/blog: route "${name}" is missing from the module manifest`);
37
+ return normalizeRoute(path);
38
+ };
39
+ return { index: read("index"), glossary: read("glossary"), method: read("method"), rss: read("rss") };
40
+ }
41
+
42
+ /** The path of the cache refresh route (`refreshBlogCache`), outside the pages' routes. */
43
+ export function getBlogRefreshPath(config: SoftureConfig): string {
44
+ const path = getBlogModule(config).routes.refresh;
45
+ // The manifest declares the route, so a missing one means a broken module definition.
46
+ if (path === undefined) throw new Error('@softure-ai/blog: route "refresh" is missing from the module manifest');
47
+ return normalizeRoute(path);
48
+ }
49
+
50
+ /** The slugs no article may take: the app's `reservedSlugs` and the static pages under the listing. */
51
+ export function getBlogReservedSlugs(config: SoftureConfig): string[] {
52
+ const options = getBlogOptions(config);
53
+ return getReservedSlugs(getBlogRoutes(config), options);
54
+ }
55
+
56
+ /** The quality gate's settings, or `null` when the app turned the gate off (`quality: false`). */
57
+ export function getQualitySettings(config: SoftureConfig): QualitySettings | null {
58
+ const { quality, images } = getBlogOptions(config);
59
+ if (quality === false) return null;
60
+ const site = { appOrigin: config.appOrigin, siteOrigin: getSiteUrls(config).origin, timezone: config.timezone };
61
+ return resolveQualitySettings(quality, site, images ?? null);
62
+ }
package/src/sitemap.ts ADDED
@@ -0,0 +1,36 @@
1
+ // The sitemap contributor for `@softure-ai/seo`, in the root entry because `softure.config.ts` imports
2
+ // it. That file also loads in plain Node and in bundles made outside Next (`softure migrate`, the blog
3
+ // command, a container's migrate script), so nothing here may reach `next/*`: the entries come from
4
+ // one query on the shared database, not from the pages' Next cache. Crawlers read a sitemap rarely.
5
+ import { systemClock, type SoftureConfig } from "@softure-ai/core";
6
+ import { getSoftureConfig } from "@softure-ai/core/next";
7
+ import { getSharedDatabase } from "@softure-ai/db";
8
+ import { listArticles } from "./db/articles.js";
9
+ import { getBlogSitemapEntries, type BlogSitemapEntry } from "./discovery/sitemap.js";
10
+ import { getBlogOptions, getBlogRoutes } from "./server/options.js";
11
+
12
+ /** The listing, the articles, the glossary, its terms and the method page, each dated by its last content change. */
13
+ export async function readBlogSitemap(config: SoftureConfig): Promise<BlogSitemapEntry[]> {
14
+ if (config.database === null) {
15
+ // Unreachable for a validated config: the module has a database schema.
16
+ throw new Error("@softure-ai/blog: softure.config.ts has no database; the blog sitemap needs one");
17
+ }
18
+ const { db } = await getSharedDatabase(config.database.url);
19
+ const texts = await listArticles({ db, clock: systemClock, config });
20
+ const routes = getBlogRoutes(config);
21
+ return getBlogSitemapEntries({
22
+ articles: texts.filter((text) => text.kind === "article"),
23
+ terms: texts.filter((text) => text.kind === "term"),
24
+ routes,
25
+ methodPath: getBlogOptions(config).methodPage ? routes.method : null,
26
+ });
27
+ }
28
+
29
+ /**
30
+ * `seo({ sitemap: { contributors: [blogSitemap()] } })`. The config is read when the sitemap is
31
+ * requested (it is still being defined when this is called); pass one to read another. A failed read
32
+ * throws; seo logs it and serves the rest. Mount `app/sitemap.ts` with `dynamic = "force-dynamic"`.
33
+ */
34
+ export function blogSitemap(config?: SoftureConfig): () => Promise<BlogSitemapEntry[]> {
35
+ return () => readBlogSitemap(config ?? getSoftureConfig());
36
+ }
@@ -0,0 +1,186 @@
1
+ // The article page, answer first: crumbs, title, description and dates; then the "in short" box, the
2
+ // contents (from two level-2 headings up), the body, questions and answers, sources, the signature
3
+ // and the disclaimer; then the app's slots. Everything is server HTML: a crawler without JavaScript
4
+ // gets the whole text.
5
+ import { formatMessage } from "@softure-ai/core";
6
+ import type { ReactNode } from "react";
7
+ import type { BlogArticle, BlogSource } from "../contract.js";
8
+ import { formatDay, type ArticleDates } from "../pages/dates.js";
9
+ import type { Crumb } from "../pages/listing.js";
10
+ import { getArticlePath } from "../pages/paths.js";
11
+ import type { ArticleHeading, ArticleSegment } from "../render/render-article.js";
12
+ import { BlogFooterNote, BlogLayout, JsonLdScript } from "./blog-layout.js";
13
+ import type { BlogPageContext } from "./page-context.js";
14
+
15
+ export interface RenderedBody {
16
+ readonly segments: readonly ArticleSegment<ReactNode>[];
17
+ readonly headings: readonly ArticleHeading[];
18
+ readonly readingMinutes: number;
19
+ }
20
+
21
+ export interface BlogArticleViewProps {
22
+ readonly context: BlogPageContext;
23
+ readonly article: BlogArticle;
24
+ readonly dates: ArticleDates;
25
+ readonly body: RenderedBody;
26
+ readonly crumbs: readonly Crumb[];
27
+ /** From `serializeJsonLd(getArticleJsonLd(…))`. */
28
+ readonly jsonLd: string;
29
+ /** The app's call to action, right after the text. */
30
+ readonly cta?: ReactNode;
31
+ /** The app's block under the article, e.g. `<Waitlist placement="blog" />`. */
32
+ readonly afterArticle?: ReactNode;
33
+ /** "Read next" (`getRelatedArticles`); none or empty: no section. */
34
+ readonly related?: readonly Pick<BlogArticle, "id" | "slug" | "title" | "description">[];
35
+ }
36
+
37
+ /** The body in order: HTML segments from the renderer, node segments (the app's block plugins) as they are. */
38
+ export function ArticleBody({ segments }: { readonly segments: readonly ArticleSegment<ReactNode>[] }) {
39
+ return (
40
+ <div className="blog-body">
41
+ {segments.map((segment, index) =>
42
+ segment.kind === "html" ? (
43
+ // The renderer's output is the security boundary: raw HTML escaped, unsafe links dropped.
44
+ <div key={index} className="blog-segment" dangerouslySetInnerHTML={{ __html: segment.html }} />
45
+ ) : (
46
+ <div key={index} className="blog-segment" data-block={segment.type}>
47
+ {segment.node}
48
+ </div>
49
+ ),
50
+ )}
51
+ </div>
52
+ );
53
+ }
54
+
55
+ function DateItem({ label, day, context }: { readonly label: string; readonly day: string; readonly context: BlogPageContext }) {
56
+ return (
57
+ <div>
58
+ <dt>{label}</dt>
59
+ <dd>
60
+ <time dateTime={day}>{formatDay(day, context.locale)}</time>
61
+ </dd>
62
+ </div>
63
+ );
64
+ }
65
+
66
+ export function ArticleDatesList({ dates, readingMinutes, context }: { readonly dates: ArticleDates; readonly readingMinutes?: number; readonly context: BlogPageContext }) {
67
+ const copy = context.messages.pages;
68
+ return (
69
+ <dl className="blog-dates">
70
+ <DateItem label={copy.published} day={dates.published} context={context} />
71
+ {dates.updated === null ? null : <DateItem label={copy.updated} day={dates.updated} context={context} />}
72
+ <DateItem label={copy.currentAsOf} day={dates.currentAsOf} context={context} />
73
+ {readingMinutes === undefined ? null : (
74
+ <div>
75
+ <dt className="blog-visually-hidden">{copy.readingTimeLabel}</dt>
76
+ <dd>{formatMessage(copy.readingTime, { minutes: readingMinutes })}</dd>
77
+ </div>
78
+ )}
79
+ </dl>
80
+ );
81
+ }
82
+
83
+ export function SourceList({ sources, heading }: { readonly sources: readonly BlogSource[]; readonly heading: string }) {
84
+ if (sources.length === 0) return null;
85
+ return (
86
+ <section aria-labelledby="blog-sources" className="blog-section blog-sources">
87
+ <h2 id="blog-sources">{heading}</h2>
88
+ <ul>
89
+ {sources.map((source) => (
90
+ <li key={source.url}>
91
+ <a href={source.url} rel="noopener noreferrer" target="_blank">
92
+ {source.name}
93
+ </a>
94
+ </li>
95
+ ))}
96
+ </ul>
97
+ </section>
98
+ );
99
+ }
100
+
101
+ /**
102
+ * "Read next": texts of the same cluster, then of others, chosen without manual lists. After the app's
103
+ * call to action: the text has just made its point, so the app's next step comes first, this second.
104
+ */
105
+ export function RelatedList({ articles, context }: { readonly articles: readonly Pick<BlogArticle, "id" | "slug" | "title" | "description">[]; readonly context: BlogPageContext }) {
106
+ if (articles.length === 0) return null;
107
+ return (
108
+ <section aria-labelledby="blog-related" className="blog-section blog-related">
109
+ <h2 id="blog-related">{context.messages.pages.readNext}</h2>
110
+ <ul>
111
+ {articles.map((article) => (
112
+ <li key={article.id}>
113
+ <h3>
114
+ <a href={getArticlePath(context.routes, article.slug)}>{article.title}</a>
115
+ </h3>
116
+ <p>{article.description}</p>
117
+ </li>
118
+ ))}
119
+ </ul>
120
+ </section>
121
+ );
122
+ }
123
+
124
+ function Contents({ headings, label }: { readonly headings: readonly ArticleHeading[]; readonly label: string }) {
125
+ const sections = headings.filter((heading) => heading.level === 2);
126
+ if (sections.length < 2) return null;
127
+ return (
128
+ <nav aria-labelledby="blog-contents" className="blog-contents">
129
+ <p id="blog-contents" className="blog-contents-title">
130
+ {label}
131
+ </p>
132
+ <ol>
133
+ {sections.map((heading) => (
134
+ <li key={heading.id}>
135
+ <a href={`#${heading.id}`}>{heading.text}</a>
136
+ </li>
137
+ ))}
138
+ </ol>
139
+ </nav>
140
+ );
141
+ }
142
+
143
+ export function BlogArticleView({ context, article, dates, body, crumbs, jsonLd, cta, afterArticle, related = [] }: BlogArticleViewProps) {
144
+ const copy = context.messages.pages;
145
+ return (
146
+ <BlogLayout
147
+ context={context}
148
+ title={article.title}
149
+ lead={article.description}
150
+ crumbs={crumbs}
151
+ meta={<ArticleDatesList dates={dates} readingMinutes={body.readingMinutes} context={context} />}
152
+ >
153
+ <JsonLdScript json={jsonLd} />
154
+ <div className="blog-article-layout">
155
+ <Contents headings={body.headings} label={copy.contents} />
156
+ <article className="blog-article">
157
+ {article.summary === null ? null : (
158
+ <aside aria-labelledby="blog-summary" className="blog-summary">
159
+ <h2 id="blog-summary">{copy.summary}</h2>
160
+ <p>{article.summary}</p>
161
+ </aside>
162
+ )}
163
+ <ArticleBody segments={body.segments} />
164
+ {article.faq.length === 0 ? null : (
165
+ <section aria-labelledby="blog-faq" className="blog-section blog-faq">
166
+ <h2 id="blog-faq">{copy.faq}</h2>
167
+ <dl>
168
+ {article.faq.map((entry) => (
169
+ <div key={entry.question}>
170
+ <dt>{entry.question}</dt>
171
+ <dd>{entry.answer}</dd>
172
+ </div>
173
+ ))}
174
+ </dl>
175
+ </section>
176
+ )}
177
+ <SourceList sources={article.sources} heading={copy.sources} />
178
+ <BlogFooterNote context={context} />
179
+ </article>
180
+ </div>
181
+ {cta === undefined ? null : <div className="blog-slot">{cta}</div>}
182
+ <RelatedList articles={related} context={context} />
183
+ {afterArticle === undefined ? null : <div className="blog-slot">{afterArticle}</div>}
184
+ </BlogLayout>
185
+ );
186
+ }
@@ -0,0 +1,104 @@
1
+ // The glossary: the index (terms in alphabetical order with their definitions) and a term page
2
+ // (definition first, the body, sources, the articles that expand on the term).
3
+ import type { ReactNode } from "react";
4
+ import type { BlogArticle } from "../contract.js";
5
+ import { formatDay, type ArticleDates } from "../pages/dates.js";
6
+ import type { Crumb } from "../pages/listing.js";
7
+ import { getArticlePath, getTermPath } from "../pages/paths.js";
8
+ import { ArticleBody, SourceList, type RenderedBody } from "./blog-article.js";
9
+ import { BlogFooterNote, BlogLayout, JsonLdScript } from "./blog-layout.js";
10
+ import type { BlogPageContext } from "./page-context.js";
11
+
12
+ export interface GlossaryIndexViewProps {
13
+ readonly context: BlogPageContext;
14
+ /** Already sorted (`sortTerms`). */
15
+ readonly terms: readonly BlogArticle[];
16
+ /** `null` with no terms: an empty set says nothing. */
17
+ readonly jsonLd: string | null;
18
+ }
19
+
20
+ export function GlossaryIndexView({ context, terms, jsonLd }: GlossaryIndexViewProps) {
21
+ const copy = context.messages.glossary;
22
+ const crumbs: Crumb[] = [
23
+ { name: context.messages.pages.blogTitle, path: context.routes.index },
24
+ { name: copy.title, path: context.routes.glossary },
25
+ ];
26
+ return (
27
+ <BlogLayout context={context} title={copy.title} lead={copy.description} crumbs={crumbs}>
28
+ {jsonLd === null ? null : <JsonLdScript json={jsonLd} />}
29
+ {terms.length === 0 ? (
30
+ <p className="blog-empty">{copy.empty}</p>
31
+ ) : (
32
+ <dl className="blog-terms">
33
+ {terms.map((term) => (
34
+ <div key={term.id}>
35
+ <dt>
36
+ <a href={getTermPath(context.routes, term.slug)}>{term.title}</a>
37
+ </dt>
38
+ <dd>{term.description}</dd>
39
+ </div>
40
+ ))}
41
+ </dl>
42
+ )}
43
+ <BlogFooterNote context={context} />
44
+ </BlogLayout>
45
+ );
46
+ }
47
+
48
+ export interface GlossaryTermViewProps {
49
+ readonly context: BlogPageContext;
50
+ readonly term: BlogArticle;
51
+ readonly dates: ArticleDates;
52
+ readonly body: RenderedBody;
53
+ readonly crumbs: readonly Crumb[];
54
+ readonly jsonLd: string;
55
+ /** Published articles whose text links this term. */
56
+ readonly articles: readonly BlogArticle[];
57
+ readonly cta?: ReactNode;
58
+ }
59
+
60
+ export function GlossaryTermView({ context, term, dates, body, crumbs, jsonLd, articles, cta }: GlossaryTermViewProps) {
61
+ const pages = context.messages.pages;
62
+ const copy = context.messages.glossary;
63
+ const meta = (
64
+ <dl className="blog-dates">
65
+ <div>
66
+ <dt>{pages.currentAsOf}</dt>
67
+ <dd>
68
+ <time dateTime={dates.currentAsOf}>{formatDay(dates.currentAsOf, context.locale)}</time>
69
+ </dd>
70
+ </div>
71
+ {dates.updated === null ? null : (
72
+ <div>
73
+ <dt>{pages.updated}</dt>
74
+ <dd>
75
+ <time dateTime={dates.updated}>{formatDay(dates.updated, context.locale)}</time>
76
+ </dd>
77
+ </div>
78
+ )}
79
+ </dl>
80
+ );
81
+ return (
82
+ <BlogLayout context={context} title={term.title} lead={term.description} crumbs={crumbs} meta={meta}>
83
+ <JsonLdScript json={jsonLd} />
84
+ <article className="blog-article">
85
+ <ArticleBody segments={body.segments} />
86
+ <SourceList sources={term.sources} heading={pages.sources} />
87
+ {articles.length === 0 ? null : (
88
+ <section aria-labelledby="blog-explained-in" className="blog-section blog-explained-in">
89
+ <h2 id="blog-explained-in">{copy.explainedIn}</h2>
90
+ <ul>
91
+ {articles.map((article) => (
92
+ <li key={article.id}>
93
+ <a href={getArticlePath(context.routes, article.slug)}>{article.title}</a>
94
+ </li>
95
+ ))}
96
+ </ul>
97
+ </section>
98
+ )}
99
+ <BlogFooterNote context={context} extraLink={{ href: context.routes.glossary, label: copy.allTerms }} />
100
+ </article>
101
+ {cta === undefined ? null : <div className="blog-slot">{cta}</div>}
102
+ </BlogLayout>
103
+ );
104
+ }
@@ -0,0 +1,89 @@
1
+ // The frame of every blog page: crumbs, the title with its lead, an optional line under it (dates),
2
+ // then the content. Links are plain anchors: the pages render on the server and need no client router.
3
+ import { formatMessage } from "@softure-ai/core";
4
+ import type { ReactNode } from "react";
5
+ import type { Crumb } from "../pages/listing.js";
6
+ import type { BlogPageContext } from "./page-context.js";
7
+
8
+ export interface BlogLayoutProps {
9
+ readonly context: BlogPageContext;
10
+ readonly title: string;
11
+ readonly lead?: string;
12
+ /** Crumbs up to the page itself; the last one is announced, not shown. */
13
+ readonly crumbs?: readonly Crumb[];
14
+ /** Under the title: dates, reading time. */
15
+ readonly meta?: ReactNode;
16
+ readonly children: ReactNode;
17
+ }
18
+
19
+ export function Breadcrumbs({ crumbs, label }: { readonly crumbs: readonly Crumb[]; readonly label: string }) {
20
+ return (
21
+ <nav aria-label={label} className="blog-crumbs">
22
+ <ol>
23
+ {crumbs.map((crumb, index) =>
24
+ index === crumbs.length - 1 ? (
25
+ <li key={crumb.path} className="blog-visually-hidden" aria-current="page">
26
+ {crumb.name}
27
+ </li>
28
+ ) : (
29
+ <li key={crumb.path}>
30
+ <a href={crumb.path}>{crumb.name}</a>
31
+ <span aria-hidden="true" className="blog-crumbs-separator">
32
+ {"›"}
33
+ </span>
34
+ </li>
35
+ ),
36
+ )}
37
+ </ol>
38
+ </nav>
39
+ );
40
+ }
41
+
42
+ export function BlogLayout({ context, title, lead, crumbs, meta, children }: BlogLayoutProps) {
43
+ return (
44
+ <main className="blog-page">
45
+ <header className="blog-header">
46
+ {crumbs !== undefined && crumbs.length > 1 ? <Breadcrumbs crumbs={crumbs} label={context.messages.pages.breadcrumbs} /> : null}
47
+ <h1 className="blog-title">{title}</h1>
48
+ {lead === undefined ? null : <p className="blog-lead">{lead}</p>}
49
+ {meta}
50
+ </header>
51
+ {children}
52
+ </main>
53
+ );
54
+ }
55
+
56
+ /** The signature and the method link under a text, then the disclaimer. */
57
+ export function BlogFooterNote({ context, extraLink }: { readonly context: BlogPageContext; readonly extraLink?: { readonly href: string; readonly label: string } }) {
58
+ const copy = context.messages.pages;
59
+ const links = [
60
+ ...(extraLink === undefined ? [] : [extraLink]),
61
+ ...(context.methodPath === null ? [] : [{ href: context.methodPath, label: copy.methodLink }]),
62
+ ];
63
+ const hasSignature = context.brand !== null;
64
+ return (
65
+ <footer className="blog-footer-note">
66
+ {hasSignature || links.length > 0 ? (
67
+ <p className="blog-signature">
68
+ {context.brand === null ? null : <strong>{formatMessage(copy.signature, { brand: context.brand })}</strong>}
69
+ {links.map((link, index) => (
70
+ <span key={link.href}>
71
+ {hasSignature || index > 0 ? <span aria-hidden="true">{" · "}</span> : null}
72
+ <a href={link.href}>{link.label}</a>
73
+ </span>
74
+ ))}
75
+ </p>
76
+ ) : null}
77
+ {context.disclaimer === null ? null : (
78
+ <aside aria-label={copy.disclaimerLabel} className="blog-disclaimer">
79
+ <p>{context.disclaimer}</p>
80
+ </aside>
81
+ )}
82
+ </footer>
83
+ );
84
+ }
85
+
86
+ /** Structured data for search engines and assistants; `json` comes from `serializeJsonLd`. */
87
+ export function JsonLdScript({ json }: { readonly json: string }) {
88
+ return <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: json }} />;
89
+ }
@@ -0,0 +1,95 @@
1
+ // The listing: articles as cards in groups by cluster, the pillar of a cluster as a lead card before
2
+ // the grid. A whole card is one link (its title's anchor stretches over the card in CSS), so a screen
3
+ // reader hears one link named after the text and a finger can hit anywhere.
4
+ import { formatMessage } from "@softure-ai/core";
5
+ import type { ReactNode } from "react";
6
+ import type { BlogArticle } from "../contract.js";
7
+ import { formatDay, getArticleDates } from "../pages/dates.js";
8
+ import { getClusterAnchor, type ClusterGroup } from "../pages/listing.js";
9
+ import { getArticlePath } from "../pages/paths.js";
10
+ import { getReadingMinutes } from "../render/reading-time.js";
11
+ import { BlogFooterNote, BlogLayout } from "./blog-layout.js";
12
+ import type { BlogPageContext } from "./page-context.js";
13
+
14
+ export interface BlogListingViewProps {
15
+ readonly context: BlogPageContext;
16
+ readonly groups: readonly ClusterGroup<BlogArticle>[];
17
+ /** Published glossary terms; the glossary link shows when there is at least one. */
18
+ readonly termCount: number;
19
+ readonly timezone: string;
20
+ /** The app's call to action under the cards. */
21
+ readonly cta?: ReactNode;
22
+ }
23
+
24
+ function CardMeta({ article, context, timezone }: { readonly article: BlogArticle; readonly context: BlogPageContext; readonly timezone: string }) {
25
+ const copy = context.messages.pages;
26
+ const dates = getArticleDates(article, timezone);
27
+ const shownDay = dates.updated ?? dates.published;
28
+ const date = formatDay(shownDay, context.locale);
29
+ return (
30
+ <p className="blog-card-meta">
31
+ <time dateTime={shownDay}>{dates.updated === null ? date : formatMessage(copy.updatedOn, { date })}</time>
32
+ <span aria-hidden="true">{" · "}</span>
33
+ <span>{formatMessage(copy.readingTime, { minutes: getReadingMinutes(article.bodyMarkdown) })}</span>
34
+ </p>
35
+ );
36
+ }
37
+
38
+ function ArticleCard({ article, context, timezone, isLead }: { readonly article: BlogArticle; readonly context: BlogPageContext; readonly timezone: string; readonly isLead: boolean }) {
39
+ return (
40
+ <article className={isLead ? "blog-card blog-card-lead" : "blog-card"} data-slug={article.slug}>
41
+ {isLead ? <p className="blog-badge">{context.messages.pages.startHere}</p> : null}
42
+ <h3 className="blog-card-title">
43
+ <a className="blog-card-link" href={getArticlePath(context.routes, article.slug)}>
44
+ {article.title}
45
+ </a>
46
+ </h3>
47
+ <p className="blog-card-description">{article.description}</p>
48
+ <CardMeta article={article} context={context} timezone={timezone} />
49
+ </article>
50
+ );
51
+ }
52
+
53
+ export function BlogListingView({ context, groups, termCount, timezone, cta }: BlogListingViewProps) {
54
+ const copy = context.messages.pages;
55
+ return (
56
+ <BlogLayout context={context} title={copy.blogTitle} lead={copy.blogDescription}>
57
+ {groups.length === 0 ? (
58
+ <p className="blog-empty">{copy.empty}</p>
59
+ ) : (
60
+ <div className="blog-groups">
61
+ {groups.map((group) => {
62
+ const id = group.cluster === null ? undefined : getClusterAnchor(group.cluster);
63
+ const headingId = `${id ?? "cluster-other"}-heading`;
64
+ return (
65
+ <section key={group.cluster ?? ""} id={id} aria-labelledby={headingId} className="blog-group">
66
+ <h2 id={headingId} className="blog-group-title">
67
+ {group.label}
68
+ </h2>
69
+ {group.lead === null ? null : <ArticleCard article={group.lead} context={context} timezone={timezone} isLead />}
70
+ {group.rest.length === 0 ? null : (
71
+ <ul className="blog-cards">
72
+ {group.rest.map((article) => (
73
+ <li key={article.id}>
74
+ <ArticleCard article={article} context={context} timezone={timezone} isLead={false} />
75
+ </li>
76
+ ))}
77
+ </ul>
78
+ )}
79
+ </section>
80
+ );
81
+ })}
82
+ </div>
83
+ )}
84
+ {cta === undefined ? null : <div className="blog-slot">{cta}</div>}
85
+ {termCount > 0 ? (
86
+ <p className="blog-glossary-teaser">
87
+ <a href={context.routes.glossary}>{copy.glossaryLink}</a>
88
+ {": "}
89
+ {copy.glossaryTeaser}
90
+ </p>
91
+ ) : null}
92
+ <BlogFooterNote context={context} />
93
+ </BlogLayout>
94
+ );
95
+ }
@@ -0,0 +1,34 @@
1
+ // "How our texts are made": who writes, where facts come from, sources, checks and corrections. The
2
+ // copy is the module's default in `messages.method`; an app states its own method through
3
+ // `blog({ messages: { en: { method: { … } } } })`.
4
+ import { BlogFooterNote, BlogLayout } from "./blog-layout.js";
5
+ import type { BlogPageContext } from "./page-context.js";
6
+
7
+ const SECTIONS = [
8
+ ["who", "whoTitle", "whoBody"],
9
+ ["facts", "factsTitle", "factsBody"],
10
+ ["sources", "sourcesTitle", "sourcesBody"],
11
+ ["checks", "checksTitle", "checksBody"],
12
+ ["corrections", "correctionsTitle", "correctionsBody"],
13
+ ] as const;
14
+
15
+ export function BlogMethodView({ context }: { readonly context: BlogPageContext }) {
16
+ const copy = context.messages.method;
17
+ const crumbs = [
18
+ { name: context.messages.pages.blogTitle, path: context.routes.index },
19
+ { name: copy.title, path: context.methodPath ?? context.routes.index },
20
+ ];
21
+ return (
22
+ <BlogLayout context={context} title={copy.title} lead={copy.description} crumbs={crumbs}>
23
+ <div className="blog-article">
24
+ {SECTIONS.map(([id, title, body]) => (
25
+ <section key={id} id={id} aria-labelledby={`${id}-heading`} className="blog-section">
26
+ <h2 id={`${id}-heading`}>{copy[title]}</h2>
27
+ <p>{copy[body]}</p>
28
+ </section>
29
+ ))}
30
+ <BlogFooterNote context={{ ...context, methodPath: null }} />
31
+ </div>
32
+ </BlogLayout>
33
+ );
34
+ }
@@ -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, type BlogArticleViewProps, type RenderedBody } from "./blog-article.js";
4
+ export { GlossaryIndexView, GlossaryTermView, type GlossaryIndexViewProps, type GlossaryTermViewProps } from "./blog-glossary.js";
5
+ export { BlogFooterNote, BlogLayout, Breadcrumbs, JsonLdScript, type BlogLayoutProps } from "./blog-layout.js";
6
+ export { BlogListingView, type BlogListingViewProps } from "./blog-listing.js";
7
+ export { BlogMethodView } from "./blog-method.js";
8
+ export type { BlogPageContext } from "./page-context.js";
@@ -0,0 +1,17 @@
1
+ // What every blog component needs besides its own data: the copy, the locale, the paths and the
2
+ // app's brand and disclaimer. The Next adapter builds it once per request from the config.
3
+ import type { Locale } from "@softure-ai/core";
4
+ import type { BlogMessages } from "../messages/index.js";
5
+ import type { BlogRoutes } from "../pages/paths.js";
6
+
7
+ export interface BlogPageContext {
8
+ readonly messages: BlogMessages;
9
+ readonly locale: Locale;
10
+ readonly routes: BlogRoutes;
11
+ /** The method page's path; `null` when the app does not mount it (nothing links to it then). */
12
+ readonly methodPath: string | null;
13
+ /** The site's name for the signature; `null` without a brand. */
14
+ readonly brand: string | null;
15
+ /** The note under every text in the locale; `null` without one. */
16
+ readonly disclaimer: string | null;
17
+ }