@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
package/src/index.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Public API of @softure-ai/blog: the module factory for softure.config.ts, its options, the content
2
+ // contract and the tables. Parsing article files, the store, the publish run and the read functions
3
+ // are in `@softure-ai/blog/server`, the `softure-blog` command in `/cli`.
4
+ import { defineModule, resolveMigrationsDir } from "@softure-ai/core";
5
+ import { blogMessages } from "./messages/index.js";
6
+ import { BLOG_REFRESH_RATE_LIMIT_BUCKET, BLOG_REFRESH_SECRET_ENV } from "./discovery/refresh.js";
7
+ import { blogOptionsSchema } from "./options.js";
8
+ import { checkArticlesTable } from "./server/health.js";
9
+
10
+ export const MODULE_ID = "blog";
11
+
12
+ /**
13
+ * The rate limit bucket of the cache refresh route (`refreshBlogCache`), to spread into
14
+ * `security({ buckets })`: requests per client address, counted before the secret is checked. A
15
+ * publish sends one; the limit leaves room for a few retries and stops guessing.
16
+ */
17
+ export const BLOG_RATE_LIMIT_BUCKETS = {
18
+ [BLOG_REFRESH_RATE_LIMIT_BUCKET]: { limit: 10, windowMinutes: 15 },
19
+ } as const;
20
+
21
+ /**
22
+ * Enables the blog in `softure.config.ts`: `blog()`, or with options
23
+ * `blog({ brand: { name: "Example" }, methodPage: true, fields: z.object({ scenario: z.string().optional() }) })`.
24
+ * Pages mount under `routes` (`index` `/blog`, `glossary` `/blog/glossary`, `method` `/blog/how-we-write`,
25
+ * `rss` `/blog/rss.xml`). With `seo()` listed too, `blogSitemap()` feeds its sitemap and a publish pings IndexNow.
26
+ * With `security()` listed and `refreshBlogCache` mounted at `refresh` (`/api/blog/refresh`), a publish from
27
+ * the command refreshes the running app's cache.
28
+ */
29
+ export const blog = defineModule({
30
+ manifest: {
31
+ id: MODULE_ID,
32
+ version: "0.1.5",
33
+ // seo is optional: with it the sitemap lists the texts and a publish pings IndexNow. security is
34
+ // optional too: only the cache refresh route needs it, for its rate limit.
35
+ dependsOn: { seo: "^0.1.0?", security: "^0.1.0?" },
36
+ dbSchema: "blog",
37
+ tables: ["articles", "slug_history"],
38
+ env: [
39
+ {
40
+ name: BLOG_REFRESH_SECRET_ENV,
41
+ required: false,
42
+ description:
43
+ "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.",
44
+ },
45
+ ],
46
+ switches: [],
47
+ routes: { index: "/blog", glossary: "/blog/glossary", method: "/blog/how-we-write", rss: "/blog/rss.xml", refresh: "/api/blog/refresh" },
48
+ mount: [
49
+ { kind: "page", path: "app/blog/page.tsx", export: "BlogIndexPage" },
50
+ { kind: "page", path: "app/blog/[slug]/page.tsx", export: "BlogArticlePage" },
51
+ { kind: "route-handler", path: "app/blog/[slug]/opengraph-image.tsx", export: "BlogArticleOgImage" },
52
+ { kind: "page", path: "app/blog/glossary/page.tsx", export: "GlossaryIndexPage" },
53
+ { kind: "page", path: "app/blog/glossary/[slug]/page.tsx", export: "GlossaryTermPage" },
54
+ { kind: "page", path: "app/blog/how-we-write/page.tsx", export: "BlogMethodPage" },
55
+ { kind: "route-handler", path: "app/blog/rss.xml/route.ts", export: "serveBlogRss" },
56
+ { kind: "route-handler", path: "app/api/blog/refresh/route.ts", export: "refreshBlogCache" },
57
+ { kind: "middleware", path: "proxy.ts", export: "createBlogRedirects" },
58
+ ],
59
+ privacy: { exports: false, deletes: false },
60
+ },
61
+ messages: blogMessages,
62
+ options: blogOptionsSchema,
63
+ migrations: { dir: resolveMigrationsDir(import.meta.url, "../migrations/") },
64
+ health: checkArticlesTable,
65
+ });
66
+
67
+ export {
68
+ BLOG_ARTICLE_KINDS,
69
+ BLOG_ARTICLE_STATUSES,
70
+ type BlogArticle,
71
+ type BlogArticleContent,
72
+ type BlogArticleInput,
73
+ type BlogArticleKind,
74
+ type BlogArticleState,
75
+ type BlogArticleStatus,
76
+ type BlogFaqEntry,
77
+ type BlogFields,
78
+ type BlogPublishAction,
79
+ type BlogPublishResult,
80
+ type BlogSlugErrorCode,
81
+ type BlogSource,
82
+ } from "./contract.js";
83
+ export { blogMessages, type BlogMessages } from "./messages/index.js";
84
+ export {
85
+ DEFAULT_CONTENT_DIR,
86
+ DEFAULT_REVALIDATE_SECONDS,
87
+ FRONTMATTER_KEYS,
88
+ type BlogFieldsParseResult,
89
+ type BlogFieldsSchema,
90
+ type BlogOptions,
91
+ type BlogOptionsInput,
92
+ type BlogSkillSection,
93
+ type LocalizedText,
94
+ } from "./options.js";
95
+ export { articles, blogSchema, slugHistory } from "./db/schema.js";
96
+ export type { BlogSitemapEntry } from "./discovery/sitemap.js";
97
+ export { blogSitemap, readBlogSitemap } from "./sitemap.js";
98
+ /** Builds a voice phrase for `blog({ quality: { voice: { phrases } } })` in the config. */
99
+ export { wordPattern } from "./quality/text.js";
@@ -0,0 +1,82 @@
1
+ export const en = {
2
+ kinds: {
3
+ article: "Article",
4
+ term: "Glossary term",
5
+ },
6
+ statuses: {
7
+ draft: "Draft",
8
+ published: "Published",
9
+ withdrawn: "Withdrawn",
10
+ },
11
+ render: {
12
+ footnotesHeading: "Notes",
13
+ /** `{number}`: the footnote number. */
14
+ footnoteLabel: "Footnote {number}",
15
+ backToText: "Back to text",
16
+ opensInNewTab: "(opens in a new tab)",
17
+ tableOfContents: "Contents",
18
+ },
19
+ pages: {
20
+ blogTitle: "Blog",
21
+ blogDescription: "Articles that explain the topic step by step, with sources and the day the facts were checked.",
22
+ /** A page title with the brand: `{title}` the page, `{brand}` the site. */
23
+ titleWithBrand: "{title} | {brand}",
24
+ empty: "The first texts are on their way.",
25
+ otherCluster: "Other texts",
26
+ startHere: "Start here",
27
+ /** `{minutes}`: whole minutes, at least 1 ("min" does not inflect, so no plural forms). */
28
+ readingTime: "{minutes} min read",
29
+ /** `{date}`: the day of the last change, on a card. */
30
+ updatedOn: "Updated {date}",
31
+ breadcrumbs: "Breadcrumbs",
32
+ published: "Published",
33
+ updated: "Updated",
34
+ currentAsOf: "Current as of",
35
+ readingTimeLabel: "Reading time",
36
+ contents: "In this text",
37
+ summary: "In short",
38
+ faq: "Questions and answers",
39
+ sources: "Sources",
40
+ /** `{brand}`: the site's name; shown only when the app sets a brand. */
41
+ signature: "{brand} editorial team",
42
+ methodLink: "How our texts are made",
43
+ disclaimerLabel: "Disclaimer",
44
+ glossaryLink: "Glossary",
45
+ glossaryTeaser: "short definitions of the terms the texts use.",
46
+ readNext: "Read next",
47
+ },
48
+ glossary: {
49
+ title: "Glossary",
50
+ description: "Short definitions of the terms the texts use, each with the day it was checked and its sources.",
51
+ empty: "The first definitions are on their way.",
52
+ explainedIn: "Explained in these texts",
53
+ allTerms: "All terms",
54
+ },
55
+ method: {
56
+ title: "How our texts are made",
57
+ description: "Who writes the texts, where the facts come from and what is checked before a text is published.",
58
+ whoTitle: "Who writes",
59
+ whoBody: "The editorial team chooses every topic and signs every text with the site's name.",
60
+ factsTitle: "Where the facts come from",
61
+ factsBody: "Every number comes from a source listed under the text or from a calculation the text shows. Each text says on which day its facts were checked.",
62
+ sourcesTitle: "Sources",
63
+ sourcesBody: "We cite laws, official statistics and primary documents first, and link every source so you can check it yourself.",
64
+ checksTitle: "Checks before publishing",
65
+ checksBody: "Before a text goes live, automatic checks look at its structure, its links and its sources. A text that fails them is not published.",
66
+ correctionsTitle: "Corrections",
67
+ correctionsBody: "When a fact changes, we update the text and its date. A text that can no longer be corrected is withdrawn.",
68
+ },
69
+ gone: {
70
+ title: "Text withdrawn",
71
+ heading: "This text has been withdrawn",
72
+ body: "We took it down because it was no longer current or correct.",
73
+ link: "See the other texts on the blog",
74
+ },
75
+ feed: {
76
+ /** The plain-text body of a 503 when the texts cannot be read. */
77
+ unavailable: "The feed is unavailable for a moment. Try again in a few minutes.",
78
+ },
79
+ og: {
80
+ alt: "An article from the blog",
81
+ },
82
+ };
@@ -0,0 +1,7 @@
1
+ import { en } from "./en.js";
2
+ import { pl } from "./pl.js";
3
+
4
+ export type BlogMessages = typeof en;
5
+
6
+ /** Complete default dictionaries; apps pass partial overrides per locale. */
7
+ export const blogMessages = { en, pl };
@@ -0,0 +1,77 @@
1
+ export const pl = {
2
+ kinds: {
3
+ article: "Artykuł",
4
+ term: "Pojęcie w słowniku",
5
+ },
6
+ statuses: {
7
+ draft: "Szkic",
8
+ published: "Opublikowany",
9
+ withdrawn: "Wycofany",
10
+ },
11
+ render: {
12
+ footnotesHeading: "Przypisy",
13
+ footnoteLabel: "Przypis {number}",
14
+ backToText: "Wróć do tekstu",
15
+ opensInNewTab: "(otwiera się w nowej karcie)",
16
+ tableOfContents: "Spis treści",
17
+ },
18
+ pages: {
19
+ blogTitle: "Blog",
20
+ blogDescription: "Teksty, które wyjaśniają temat krok po kroku, ze źródłami i datą sprawdzenia faktów.",
21
+ titleWithBrand: "{title} | {brand}",
22
+ empty: "Pierwsze teksty są w drodze.",
23
+ otherCluster: "Pozostałe teksty",
24
+ startHere: "Od tego zacznij",
25
+ readingTime: "{minutes} min czytania",
26
+ updatedOn: "Zaktualizowano {date}",
27
+ breadcrumbs: "Okruszki",
28
+ published: "Opublikowano",
29
+ updated: "Zaktualizowano",
30
+ currentAsOf: "Aktualne na",
31
+ readingTimeLabel: "Czas czytania",
32
+ contents: "W tym tekście",
33
+ summary: "W skrócie",
34
+ faq: "Pytania i odpowiedzi",
35
+ sources: "Źródła",
36
+ signature: "Redakcja {brand}",
37
+ methodLink: "Jak powstają teksty",
38
+ disclaimerLabel: "Zastrzeżenie",
39
+ glossaryLink: "Słownik pojęć",
40
+ glossaryTeaser: "krótkie definicje pojęć, których używają teksty.",
41
+ readNext: "Przeczytaj też",
42
+ },
43
+ glossary: {
44
+ title: "Słownik pojęć",
45
+ description: "Krótkie definicje pojęć, których używają teksty, każda z datą sprawdzenia i źródłami.",
46
+ empty: "Pierwsze definicje są w drodze.",
47
+ explainedIn: "Rozwijamy w tekstach",
48
+ allTerms: "Wszystkie pojęcia",
49
+ },
50
+ method: {
51
+ title: "Jak powstają teksty",
52
+ description: "Kto pisze teksty, skąd biorą się fakty i co sprawdzamy, zanim tekst zostanie opublikowany.",
53
+ whoTitle: "Kto pisze",
54
+ whoBody: "Redakcja wybiera każdy temat i podpisuje każdy tekst nazwą serwisu.",
55
+ factsTitle: "Skąd fakty",
56
+ factsBody: "Każda liczba pochodzi ze źródła podanego pod tekstem albo z wyliczenia, które tekst pokazuje. Każdy tekst podaje dzień, w którym sprawdziliśmy fakty.",
57
+ sourcesTitle: "Źródła",
58
+ sourcesBody: "Cytujemy najpierw przepisy, oficjalne statystyki i dokumenty źródłowe, a każde źródło linkujemy, żebyś mógł je sprawdzić sam.",
59
+ checksTitle: "Sprawdzenie przed publikacją",
60
+ checksBody: "Zanim tekst trafi na stronę, automatyczne testy sprawdzają jego strukturę, linki i źródła. Tekst, który ich nie przejdzie, nie zostanie opublikowany.",
61
+ correctionsTitle: "Poprawki",
62
+ correctionsBody: "Gdy fakt się zmienia, aktualizujemy tekst i jego datę. Tekst, którego nie da się już poprawić, wycofujemy.",
63
+ },
64
+ gone: {
65
+ title: "Tekst wycofany",
66
+ heading: "Ten tekst został wycofany",
67
+ body: "Zdjęliśmy go, bo przestał być aktualny albo poprawny.",
68
+ link: "Zobacz pozostałe teksty na blogu",
69
+ },
70
+ feed: {
71
+ /** The plain-text body of a 503 when the texts cannot be read. */
72
+ unavailable: "Kanał jest chwilowo niedostępny. Spróbuj ponownie za kilka minut.",
73
+ },
74
+ og: {
75
+ alt: "Artykuł z bloga",
76
+ },
77
+ };
@@ -0,0 +1,30 @@
1
+ // What the adapter hands the store: the registered config, the process-wide database handle and the
2
+ // wall clock; and what it hands the components: the copy, paths, brand and disclaimer.
3
+ import { systemClock, type SoftureConfig } from "@softure-ai/core";
4
+ import { getSoftureConfig } from "@softure-ai/core/next";
5
+ import { getSharedDatabase } from "@softure-ai/db";
6
+ import type { BlogContext } from "../db/articles.js";
7
+ import { getBlogMessages, getBlogOptions, getBlogRoutes } from "../server/options.js";
8
+ import type { BlogPageContext } from "../ui/page-context.js";
9
+
10
+ export async function getBlogContext(config: SoftureConfig = getSoftureConfig()): Promise<BlogContext> {
11
+ if (config.database === null) {
12
+ // Unreachable for a validated config: the module has a database schema.
13
+ throw new Error("@softure-ai/blog: softure.config.ts has no database; the blog needs one");
14
+ }
15
+ const { db } = await getSharedDatabase(config.database.url);
16
+ return { db, clock: systemClock, config };
17
+ }
18
+
19
+ export function getPageContext(config: SoftureConfig): BlogPageContext {
20
+ const options = getBlogOptions(config);
21
+ const routes = getBlogRoutes(config);
22
+ return {
23
+ messages: getBlogMessages(config),
24
+ locale: config.locale,
25
+ routes,
26
+ methodPath: options.methodPage ? routes.method : null,
27
+ brand: options.brand?.name ?? null,
28
+ disclaimer: options.disclaimer === undefined ? null : (options.disclaimer[config.locale] ?? options.disclaimer.en),
29
+ };
30
+ }
@@ -0,0 +1,55 @@
1
+ // The pages' reads through Next's data cache: one query per key per `revalidateSeconds`, shared by the
2
+ // listing, the glossary and the metadata of a request. The listing and the glossary index render on
3
+ // every request (a build has no database), so without the cache every view would cost a query.
4
+ import type { SoftureConfig } from "@softure-ai/core";
5
+ import { unstable_cache } from "next/cache";
6
+ import type { BlogArticle } from "../contract.js";
7
+ import { findArticleBySlug, listArticles } from "../db/articles.js";
8
+ import { getBlogOptions } from "../server/options.js";
9
+ import { getBlogContext } from "./context.js";
10
+
11
+ /** The tag of every cached read: `revalidateTag("softure-blog", { expire: 0 })` refreshes the blog at once (`refreshBlogCache`). */
12
+ export const BLOG_CACHE_TAG = "softure-blog";
13
+
14
+ interface CachedReads {
15
+ readonly published: () => Promise<BlogArticle[]>;
16
+ readonly bySlug: (slug: string) => Promise<BlogArticle | null>;
17
+ }
18
+
19
+ /**
20
+ * The cache keeps JSON, so a `Date` comes back as a string: revive them, or every page served from
21
+ * the cache would fail on its dates (FIRE's bug, kept fixed).
22
+ */
23
+ function reviveDates(article: BlogArticle): BlogArticle {
24
+ const toDate = (value: Date | string | null) => (value === null ? null : new Date(value));
25
+ return { ...article, publishedAt: toDate(article.publishedAt), updatedAt: toDate(article.updatedAt), createdAt: new Date(article.createdAt) };
26
+ }
27
+
28
+ let reads: { readonly config: SoftureConfig; readonly value: CachedReads } | null = null;
29
+
30
+ function getCachedReads(config: SoftureConfig): CachedReads {
31
+ if (reads?.config === config) return reads.value;
32
+ const options = { revalidate: getBlogOptions(config).revalidateSeconds, tags: [BLOG_CACHE_TAG] };
33
+ const value: CachedReads = {
34
+ published: unstable_cache(async () => listArticles(await getBlogContext(config)), ["softure-blog", "published"], options),
35
+ bySlug: unstable_cache(async (slug: string) => findArticleBySlug(await getBlogContext(config), slug), ["softure-blog", "by-slug"], options),
36
+ };
37
+ reads = { config, value };
38
+ return value;
39
+ }
40
+
41
+ /** Published articles (not terms), newest first. */
42
+ export async function getPublishedArticles(config: SoftureConfig): Promise<BlogArticle[]> {
43
+ return (await getCachedReads(config).published()).filter((text) => text.kind === "article").map(reviveDates);
44
+ }
45
+
46
+ /** Published glossary terms, in the store's order (pages sort them). */
47
+ export async function getPublishedTerms(config: SoftureConfig): Promise<BlogArticle[]> {
48
+ return (await getCachedReads(config).published()).filter((text) => text.kind === "term").map(reviveDates);
49
+ }
50
+
51
+ /** The row under a slug in any status: the page decides what to show. */
52
+ export async function getTextBySlug(config: SoftureConfig, slug: string): Promise<BlogArticle | null> {
53
+ const text = await getCachedReads(config).bySlug(slug);
54
+ return text === null ? null : reviveDates(text);
55
+ }
@@ -0,0 +1,49 @@
1
+ // The blog's RSS feed route in Next, over the cached reads of `data.ts` (one query per
2
+ // `revalidateSeconds`). The sitemap contributor is `blogSitemap()` of the root entry (`../sitemap.ts`).
3
+ //
4
+ // app/blog/rss.xml/route.ts export { serveBlogRss as GET } from "@softure-ai/blog/next";
5
+ // export const dynamic = "force-dynamic";
6
+ import { errorLogLabel, formatMessage, getSiteUrls } from "@softure-ai/core";
7
+ import { getSoftureConfig } from "@softure-ai/core/next";
8
+ import { buildBlogRss } from "../discovery/rss.js";
9
+ import { getClusterLabel } from "../pages/listing.js";
10
+ import { getBlogOptions } from "../server/options.js";
11
+ import { getPageContext } from "./context.js";
12
+ import { getPublishedArticles, getPublishedTerms } from "./data.js";
13
+
14
+ /** Seconds a feed reader should wait after a 503: the cache's own lifetime is a fair guess. */
15
+ const RETRY_AFTER_SECONDS = 300;
16
+
17
+ /**
18
+ * `GET <routes.rss>`: the RSS 2.0 feed of the published articles and terms. A failed read answers
19
+ * **503**, never an empty feed: a reader that got a feed without items could take the texts as deleted.
20
+ */
21
+ export async function serveBlogRss(): Promise<Response> {
22
+ const config = getSoftureConfig();
23
+ const context = getPageContext(config);
24
+ try {
25
+ const [articles, terms] = await Promise.all([getPublishedArticles(config), getPublishedTerms(config)]);
26
+ const { clusters } = getBlogOptions(config);
27
+ const copy = context.messages;
28
+ const xml = buildBlogRss({
29
+ articles,
30
+ terms,
31
+ urls: getSiteUrls(config),
32
+ routes: context.routes,
33
+ feedPath: context.routes.rss,
34
+ channel: {
35
+ title: context.brand === null ? copy.pages.blogTitle : formatMessage(copy.pages.titleWithBrand, { title: copy.pages.blogTitle, brand: context.brand }),
36
+ description: copy.pages.blogDescription,
37
+ language: config.locale,
38
+ },
39
+ getCategory: (text) => (text.kind === "term" ? copy.glossary.title : text.cluster === null ? null : getClusterLabel(text.cluster, clusters, config.locale)),
40
+ });
41
+ return new Response(xml, { headers: { "content-type": "application/rss+xml; charset=utf-8" } });
42
+ } catch (error) {
43
+ console.error(`blog: the RSS feed could not read the texts: ${errorLogLabel(error)}`);
44
+ return new Response(context.messages.feed.unavailable, {
45
+ status: 503,
46
+ headers: { "content-type": "text/plain; charset=utf-8", "retry-after": String(RETRY_AFTER_SECONDS) },
47
+ });
48
+ }
49
+ }
@@ -0,0 +1,25 @@
1
+ // The Next.js adapter of @softure-ai/blog: the pages, their metadata, the article's OG image, the RSS
2
+ // feed, the cache refresh route and the cached reads (docs/02-module-standard.md §8).
3
+ export { getBlogContext, getPageContext } from "./context.js";
4
+ export { serveBlogRss } from "./discovery.js";
5
+ export { refreshBlogCache } from "./refresh.js";
6
+ export { BLOG_CACHE_TAG, getPublishedArticles, getPublishedTerms, getTextBySlug } from "./data.js";
7
+ export { createOgFontLoader, loadBrandOgFonts, type OgFontLoaderOptions, type OgFontsResult } from "./og-fonts.js";
8
+ export { BlogArticleOgImage, getOgColors, getOgFontFamily, OG_IMAGE_SIZE, renderArticleOgImage, type OgFont, type RenderArticleOgImageInput } from "./og-image.js";
9
+ export type { OgFontSource, OgFontWeight } from "../options.js";
10
+ export {
11
+ BlogArticlePage,
12
+ BlogIndexPage,
13
+ BlogMethodPage,
14
+ generateArticleMetadata,
15
+ generateBlogIndexMetadata,
16
+ generateBlogStaticParams,
17
+ generateGlossaryIndexMetadata,
18
+ generateMethodMetadata,
19
+ generateTermMetadata,
20
+ GlossaryIndexPage,
21
+ GlossaryTermPage,
22
+ type BlogArticlePageProps,
23
+ type BlogIndexPageProps,
24
+ type GlossaryTermPageProps,
25
+ } from "./pages.js";
@@ -0,0 +1,15 @@
1
+ // Next.js has no `exports` map, so NodeNext resolution (tsconfig.base.json) only finds `next/*.js`.
2
+ // The code must still import the bare specifiers: Next's bundler aliases them per runtime, and the
3
+ // `.js` form bypasses the alias (docs/02-module-standard.md §8, measured in @softure-ai/waitlist).
4
+ // These declarations give the bare specifiers their types.
5
+ declare module "next/cache" {
6
+ export * from "next/cache.js";
7
+ }
8
+
9
+ declare module "next/navigation" {
10
+ export * from "next/navigation.js";
11
+ }
12
+
13
+ declare module "next/og" {
14
+ export * from "next/og.js";
15
+ }
@@ -0,0 +1,14 @@
1
+ // The brand's fonts for the article OG card (`blog({ brand: { fonts } })`): each source is read on the
2
+ // first card and kept for the life of the process (the loader lives in `src/server/og-fonts.ts`, which
3
+ // `softure-blog check` uses too).
4
+ import type { OgFontSource } from "../options.js";
5
+ import { createOgFontLoader, type OgFontsResult } from "../server/og-fonts.js";
6
+
7
+ export { createOgFontLoader, type OgFontLoaderOptions, type OgFontsResult } from "../server/og-fonts.js";
8
+
9
+ const loadFromApp = createOgFontLoader({ root: process.cwd() });
10
+
11
+ /** The brand's fonts for the card, read from the app's root (where Next runs) and cached. */
12
+ export function loadBrandOgFonts(fonts: readonly OgFontSource[]): Promise<OgFontsResult> {
13
+ return loadFromApp(fonts);
14
+ }
@@ -0,0 +1,108 @@
1
+ // The social card of an article (`opengraph-image`): its title on the brand's background, the brand's
2
+ // name above and the blog's name below. Mount it in `app/blog/[slug]/opengraph-image.tsx`:
3
+ //
4
+ // export { BlogArticleOgImage as default, generateBlogStaticParams as generateStaticParams } from "@softure-ai/blog/next";
5
+ // export const size = { width: 1200, height: 630 };
6
+ // export const contentType = "image/png";
7
+ // export const revalidate = 300;
8
+ //
9
+ // A slug that is not a published article gets the blog's own card, never a 404: a crawler that
10
+ // fetched the page earlier would show an empty preview.
11
+ //
12
+ // The card writes in `blog({ brand: { fonts } })` when the app lists them (`next/og`'s default font
13
+ // otherwise): each file is read on the first card and kept, and a file that cannot be read fails the
14
+ // card with a message naming it. A path is read from the app's root on the Node.js runtime (the
15
+ // route's default); a route moved to the edge runtime takes https URLs only.
16
+ import { formatMessage, type SoftureConfig } from "@softure-ai/core";
17
+ import { getSoftureConfig } from "@softure-ai/core/next";
18
+ import { DEFAULT_THEME } from "@softure-ai/ui";
19
+ import { ImageResponse } from "next/og";
20
+ import type { BlogOptions } from "../options.js";
21
+ import type { OgFont } from "../server/og-fonts.js";
22
+ import { getBlogOptions } from "../server/options.js";
23
+ import { getPageContext } from "./context.js";
24
+ import { getTextBySlug } from "./data.js";
25
+ import { loadBrandOgFonts } from "./og-fonts.js";
26
+
27
+ export type { OgFont } from "../server/og-fonts.js";
28
+
29
+ /** The card's size: the 1.91:1 every network crops to. */
30
+ export const OG_IMAGE_SIZE = { width: 1200, height: 630 } as const;
31
+
32
+
33
+ export interface RenderArticleOgImageInput {
34
+ readonly title: string;
35
+ /** Under the title: the blog's name. */
36
+ readonly label: string;
37
+ readonly brand: BlogOptions["brand"];
38
+ /** Fonts for the card; `next/og`'s default font otherwise. */
39
+ readonly fonts?: readonly OgFont[];
40
+ }
41
+
42
+ /** The card's colours: the brand's, else the dark scheme of @softure-ai/ui's default theme. */
43
+ export function getOgColors(brand: BlogOptions["brand"]): { background: string; foreground: string; accent: string } {
44
+ const dark = DEFAULT_THEME.dark;
45
+ return {
46
+ background: brand?.colors?.background ?? dark["color-background"],
47
+ foreground: brand?.colors?.foreground ?? dark["color-foreground"],
48
+ accent: brand?.colors?.accent ?? dark["color-accent-fill"],
49
+ };
50
+ }
51
+
52
+ /**
53
+ * The card's `font-family`: every family it is given, once each and in order, so a second family (a
54
+ * `latin-ext` subset file under its own name) draws the characters the first lacks.
55
+ */
56
+ export function getOgFontFamily(fonts: readonly OgFont[]): string | undefined {
57
+ const names = [...new Set(fonts.map((font) => font.name))];
58
+ return names.length === 0 ? undefined : names.map((name) => JSON.stringify(name)).join(", ");
59
+ }
60
+
61
+ export function renderArticleOgImage({ title, label, brand, fonts }: RenderArticleOgImageInput): ImageResponse {
62
+ const colors = getOgColors(brand);
63
+ const fontFamily = getOgFontFamily(fonts ?? []);
64
+ return new ImageResponse(
65
+ (
66
+ <div
67
+ style={{
68
+ width: "100%",
69
+ height: "100%",
70
+ display: "flex",
71
+ flexDirection: "column",
72
+ justifyContent: "space-between",
73
+ background: colors.background,
74
+ color: colors.foreground,
75
+ padding: "56px 72px 64px",
76
+ ...(fontFamily === undefined ? {} : { fontFamily }),
77
+ }}
78
+ >
79
+ <div style={{ display: "flex", fontSize: 34, fontWeight: 700 }}>{brand?.name ?? ""}</div>
80
+ <div style={{ display: "flex", fontSize: title.length > 60 ? 56 : 68, fontWeight: 700, lineHeight: 1.08 }}>{title}</div>
81
+ <div style={{ display: "flex", alignItems: "center", gap: 14, fontSize: 28 }}>
82
+ <div style={{ display: "flex", width: 12, height: 12, borderRadius: 2, background: colors.accent }} />
83
+ <div style={{ display: "flex" }}>{label}</div>
84
+ </div>
85
+ </div>
86
+ ),
87
+ { ...OG_IMAGE_SIZE, ...(fonts === undefined ? {} : { fonts: [...fonts] }) },
88
+ );
89
+ }
90
+
91
+ function getLabel(config: SoftureConfig): string {
92
+ const context = getPageContext(config);
93
+ const name = context.messages.pages.blogTitle;
94
+ return context.brand === null ? name : formatMessage(context.messages.pages.titleWithBrand, { title: name, brand: context.brand });
95
+ }
96
+
97
+ /** The default export of an article's `opengraph-image.tsx`. */
98
+ export async function BlogArticleOgImage({ params }: { readonly params: Promise<{ readonly slug: string }> }): Promise<ImageResponse> {
99
+ const config = getSoftureConfig();
100
+ const { slug } = await params;
101
+ const article = await getTextBySlug(config, slug);
102
+ const isVisible = article !== null && article.status === "published" && article.kind === "article";
103
+ const label = getLabel(config);
104
+ const { brand } = getBlogOptions(config);
105
+ const fonts = await loadBrandOgFonts(brand?.fonts ?? []);
106
+ if (!fonts.ok) throw new Error(fonts.error);
107
+ return renderArticleOgImage({ title: isVisible ? article.title : label, label, brand, fonts: fonts.fonts.length === 0 ? undefined : fonts.fonts });
108
+ }