@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,43 @@
1
+ // Block plugin needs (BL-3): a fenced block of a block plugin reads frontmatter keys (`requires`), and
2
+ // an article that lacks one would render the block without its data.
3
+ import type { BlogArticleInput } from "../../contract.js";
4
+ import type { FoundBlock } from "../../render/render-article.js";
5
+ import type { QualityFinding } from "../finding.js";
6
+
7
+ export function checkBlockRequires(article: BlogArticleInput, pluginBlocks: readonly FoundBlock[]): QualityFinding[] {
8
+ return pluginBlocks.flatMap((block) =>
9
+ block.requires
10
+ .filter((key) => !hasFrontmatterKey(article, key))
11
+ .map((key) => ({ rule: "block-requires", severity: "error" as const, message: `the ${block.type} block needs ${key} in the frontmatter`, line: block.line })),
12
+ );
13
+ }
14
+
15
+ /** Whether the file set a frontmatter key: a module key with a value, or a key of the app's fields. */
16
+ function hasFrontmatterKey(article: BlogArticleInput, key: string): boolean {
17
+ switch (key) {
18
+ case "id":
19
+ case "slug":
20
+ case "kind":
21
+ case "title":
22
+ case "description":
23
+ case "status":
24
+ case "current_as_of":
25
+ return true;
26
+ case "cluster":
27
+ return article.cluster !== null;
28
+ case "pillar":
29
+ return article.isPillar;
30
+ case "summary":
31
+ return article.summary !== null;
32
+ case "published_at":
33
+ return article.publishedAt !== null;
34
+ case "sources":
35
+ return article.sources.length > 0;
36
+ case "faq":
37
+ return article.faq.length > 0;
38
+ case "forms":
39
+ return article.termForms.length > 0;
40
+ default:
41
+ return article.fields[key] !== undefined;
42
+ }
43
+ }
@@ -0,0 +1,34 @@
1
+ // Image rules (BF-3): every image in a body follows the app's image policy (`blog({ images })`), the
2
+ // same check the renderer applies, so a text whose image the page would drop never goes public.
3
+ import { checkArticleImage, type ArticleImagePolicy, type FoundImage, type ImageProblem, type ImageVerdict } from "../../render/images.js";
4
+ import type { QualityFinding } from "../finding.js";
5
+
6
+ const RULE_BY_PROBLEM: Record<ImageProblem, string> = { source: "image-source", alt: "image-alt", dimensions: "image-dimensions" };
7
+
8
+ function describeProblem(problem: ImageProblem, src: string, policy: ArticleImagePolicy | null): string {
9
+ switch (problem) {
10
+ case "source":
11
+ return policy === null
12
+ ? `images are off until the app sets blog({ images }): ${src}`
13
+ : `an image from outside the site and the allowed hosts (blog({ images: { hosts } })): ${src}`;
14
+ case "alt":
15
+ return `an image without alt text; describe what it shows: ${src}`;
16
+ case "dimensions":
17
+ return `the app does not know the width and height of an image (blog({ images: { dimensions } })): ${src}`;
18
+ }
19
+ }
20
+
21
+ export function checkImages(images: readonly FoundImage[], policy: ArticleImagePolicy | null): QualityFinding[] {
22
+ return images.flatMap((image) => {
23
+ let verdict: ImageVerdict;
24
+ try {
25
+ verdict = checkArticleImage(image, policy ?? undefined);
26
+ } catch (error) {
27
+ // The resolver is the app's code; its bug refuses the file instead of crashing a publish.
28
+ const detail = error instanceof Error ? error.message : String(error);
29
+ return [{ rule: "image-dimensions", severity: "error" as const, message: `the image dimensions resolver failed (${detail}): ${image.src}`, line: image.line }];
30
+ }
31
+ if (verdict.ok) return [];
32
+ return verdict.problems.map((problem) => ({ rule: RULE_BY_PROBLEM[problem], severity: "error" as const, message: describeProblem(problem, image.src, policy), line: image.line }));
33
+ });
34
+ }
@@ -0,0 +1,12 @@
1
+ // What every built-in rule reads.
2
+ import type { BlogArticleInput } from "../../contract.js";
3
+ import type { Block } from "../blocks.js";
4
+ import type { QualitySettings } from "../settings.js";
5
+
6
+ export interface RuleInput {
7
+ readonly article: BlogArticleInput;
8
+ readonly blocks: readonly Block[];
9
+ readonly settings: QualitySettings;
10
+ /** `YYYY-MM-DD` in the app's time zone. */
11
+ readonly today: string;
12
+ }
@@ -0,0 +1,56 @@
1
+ // Link rules (FIRE_TRACKER `rules-structure.ts`, `collectLinks`, `checkLinks`): enough internal links,
2
+ // each one to a page that exists, external links over https. Absolute links to the app's own origins
3
+ // count as internal. The network check of external links is `external-links.ts`.
4
+ import type { QualityFinding } from "../finding.js";
5
+ import { findBareUrls, findLinks } from "../text.js";
6
+ import type { RuleInput } from "./input.js";
7
+
8
+ export interface LinkSummary {
9
+ readonly internal: readonly { readonly pathname: string; readonly line: number }[];
10
+ readonly external: readonly { readonly url: string; readonly line: number }[];
11
+ }
12
+
13
+ /** Whether a site path (`/blog/x`, `/calculator`) leads to a page. */
14
+ export type InternalLinkResolver = (pathname: string) => boolean;
15
+
16
+ export function collectLinks({ blocks, settings }: Pick<RuleInput, "blocks" | "settings">): LinkSummary {
17
+ const internal: { pathname: string; line: number }[] = [];
18
+ const external: { url: string; line: number }[] = [];
19
+ for (const block of blocks) {
20
+ if (block.kind === "directive") continue;
21
+ const urls = findLinks(block.text).map((link) => link.url);
22
+ if (block.kind === "footnote") urls.push(...findBareUrls(block.text));
23
+ for (const url of new Set(urls)) {
24
+ const own = settings.ownOrigins.find((origin) => url === origin || url.startsWith(`${origin}/`) || url.startsWith(`${origin}?`) || url.startsWith(`${origin}#`));
25
+ if (url.startsWith("/") || own !== undefined) {
26
+ const rest = own === undefined ? url : url.slice(own.length);
27
+ internal.push({ pathname: rest.split(/[?#]/)[0] || "/", line: block.line });
28
+ } else if (/^https?:\/\//.test(url)) {
29
+ external.push({ url, line: block.line });
30
+ }
31
+ }
32
+ }
33
+ return { internal, external };
34
+ }
35
+
36
+ export function checkLinks(input: RuleInput, links: LinkSummary, resolveInternalLink: InternalLinkResolver): QualityFinding[] {
37
+ const findings: QualityFinding[] = [];
38
+ const { kind } = input.article;
39
+ const minimum = input.settings.options.limits.internalLinks[kind];
40
+ if (links.internal.length < minimum) {
41
+ findings.push({
42
+ rule: "internal-links",
43
+ severity: kind === "article" ? "error" : "warning",
44
+ message: `${String(links.internal.length)} internal links; at least ${String(minimum)} needed (other texts, the glossary, the app's tools)`,
45
+ });
46
+ }
47
+ for (const link of links.internal) {
48
+ if (!resolveInternalLink(link.pathname)) {
49
+ findings.push({ rule: "internal-link-target", severity: "error", message: `an internal link leads nowhere: ${link.pathname}`, line: link.line });
50
+ }
51
+ }
52
+ for (const link of links.external) {
53
+ if (link.url.startsWith("http://")) findings.push({ rule: "external-link-https", severity: "warning", message: `a link without https: ${link.url}`, line: link.line });
54
+ }
55
+ return findings;
56
+ }
@@ -0,0 +1,137 @@
1
+ // Structure rules (FIRE_TRACKER `src/lib/blog/quality/rules-structure.ts`): metadata lengths and
2
+ // freshness, a summary, the answer first, heading order, question sections, length and footnotes.
3
+ // Required keys, kinds, slug shape and slug = file name are BL-2's parser's, not repeated here.
4
+ import { getProseBlocks, type Block } from "../blocks.js";
5
+ import type { QualityFinding } from "../finding.js";
6
+ import { countWords, findFootnoteRefs, findSignificantNumbers, toProse } from "../text.js";
7
+ import type { RuleInput } from "./input.js";
8
+
9
+ export function checkMetadata({ article, settings, today }: RuleInput): QualityFinding[] {
10
+ const findings: QualityFinding[] = [];
11
+ const { limits } = settings.options;
12
+ if (article.title.length > limits.titleChars) {
13
+ findings.push({ rule: "title-length", severity: "warning", message: `the title has ${String(article.title.length)} characters; search engines cut it after about ${String(limits.titleChars)}` });
14
+ }
15
+ const { min, max } = limits.descriptionChars;
16
+ if (article.description.length < min || article.description.length > max) {
17
+ findings.push({ rule: "description-length", severity: "warning", message: `the description has ${String(article.description.length)} characters; aim for ${String(min)}–${String(max)}` });
18
+ }
19
+ if (article.currentAsOf > today) {
20
+ findings.push({ rule: "as-of-future", severity: "error", message: `current_as_of ${article.currentAsOf} is in the future (today is ${today})` });
21
+ } else if (daysBetween(article.currentAsOf, today) > limits.staleAfterDays) {
22
+ findings.push({ rule: "stale", severity: "warning", message: `current_as_of ${article.currentAsOf} is older than ${String(limits.staleAfterDays)} days; the text waits for fresh numbers` });
23
+ }
24
+ if (article.kind === "article" && article.summary === null) {
25
+ findings.push({ rule: "summary-missing", severity: "error", message: "the article has no summary: a few sentences with the answer for the \"in short\" box" });
26
+ }
27
+ return findings;
28
+ }
29
+
30
+ /** The text opens with a paragraph that answers; an article puts a concrete number in its first two paragraphs. */
31
+ export function checkLead({ article, blocks, settings }: RuleInput): QualityFinding[] {
32
+ const first = blocks[0];
33
+ if (first === undefined) return [{ rule: "lead", severity: "error", message: "the text is empty" }];
34
+ if (first.kind !== "paragraph") {
35
+ return [{ rule: "lead", severity: "error", message: "the text starts with a heading or a list; the first block is a paragraph with the answer", line: first.line }];
36
+ }
37
+ const findings: QualityFinding[] = [];
38
+ const limit = settings.options.limits.leadWords[article.kind];
39
+ const words = countWords(toProse(first.text));
40
+ if (words > limit) {
41
+ findings.push({ rule: "lead-length", severity: "warning", message: `the first paragraph has ${String(words)} words; the answer fits in ${String(limit)}`, line: first.line });
42
+ }
43
+ if (article.kind === "article") {
44
+ const opening = blocks.slice(0, 4).filter((block) => block.kind === "paragraph").slice(0, 2);
45
+ const hasNumber = opening.some((block) => findSignificantNumbers(toProse(block.text), settings.ruleset.notation).length > 0);
46
+ if (!hasNumber) {
47
+ findings.push({
48
+ rule: "lead-number",
49
+ severity: "error",
50
+ message: "the first two paragraphs hold no concrete number (an amount or a percentage); the claim with its number goes first",
51
+ line: first.line,
52
+ });
53
+ }
54
+ }
55
+ return findings;
56
+ }
57
+
58
+ /** No H1 (the page title is `title`), no skipped heading level; an article has question sections with short answers. */
59
+ export function checkSections({ article, blocks, settings }: RuleInput): QualityFinding[] {
60
+ const findings: QualityFinding[] = [];
61
+ let previousLevel = 1;
62
+ for (const block of blocks) {
63
+ if (block.kind !== "heading" || block.level === undefined) continue;
64
+ if (block.level === 1) {
65
+ findings.push({ rule: "heading-h1", severity: "error", message: "a # heading in the body; the page title comes from title, sections start at ##", line: block.line });
66
+ } else if (block.level > previousLevel + 1) {
67
+ findings.push({ rule: "heading-order", severity: "error", message: `heading level ${String(block.level)} follows level ${String(previousLevel)}; do not skip a level`, line: block.line });
68
+ }
69
+ previousLevel = block.level;
70
+ }
71
+ if (article.kind !== "article") return findings;
72
+
73
+ const sections = blocks.filter((block) => block.kind === "heading" && block.level === 2);
74
+ if (sections.length < 2) {
75
+ findings.push({ rule: "sections", severity: "error", message: `the article has ${String(sections.length)} ## sections; it needs at least 2` });
76
+ }
77
+ const questions = sections.filter((block) => block.text.trim().endsWith("?"));
78
+ if (questions.length === 0) {
79
+ findings.push({ rule: "section-question", severity: "error", message: "no ## heading is a question; answer sections are what AI assistants quote" });
80
+ }
81
+ const limit = settings.options.limits.answerWords;
82
+ for (const question of questions) {
83
+ const answer = blocks[blocks.indexOf(question) + 1];
84
+ if (answer?.kind !== "paragraph") {
85
+ findings.push({ rule: "section-answer", severity: "error", message: `no paragraph with the answer under "${question.text}"`, line: question.line });
86
+ continue;
87
+ }
88
+ const words = countWords(toProse(answer.text));
89
+ if (words > limit) {
90
+ findings.push({
91
+ rule: "section-answer-length",
92
+ severity: "warning",
93
+ message: `the answer under "${question.text}" has ${String(words)} words; the first sentences answer directly (at most ${String(limit)})`,
94
+ line: answer.line,
95
+ });
96
+ }
97
+ }
98
+ return findings;
99
+ }
100
+
101
+ export function checkLength({ article, blocks, settings }: RuleInput): QualityFinding[] {
102
+ const words = getProseBlocks(blocks).reduce((sum, block) => sum + countWords(toProse(block.text)), 0);
103
+ const { min, max } = settings.options.limits.words[article.kind];
104
+ if (words < min) return [{ rule: "length", severity: "error", message: `the text has ${String(words)} words; ${article.kind === "article" ? "an article" : "a term"} needs at least ${String(min)}` }];
105
+ if (words > max) return [{ rule: "length", severity: "warning", message: `the text has ${String(words)} words; above ${String(max)} readers and assistants lose the answer` }];
106
+ return [];
107
+ }
108
+
109
+ /** Every footnote reference has a definition, and every definition is used. */
110
+ export function checkFootnotes({ blocks }: RuleInput): QualityFinding[] {
111
+ const findings: QualityFinding[] = [];
112
+ const definitions = getFootnoteDefinitions(blocks);
113
+ const used = new Set<string>();
114
+ for (const block of blocks) {
115
+ if (block.kind === "footnote" || block.kind === "directive") continue;
116
+ for (const ref of findFootnoteRefs(block.text)) {
117
+ used.add(ref);
118
+ if (!definitions.has(ref)) findings.push({ rule: "footnote-undefined", severity: "error", message: `footnote [^${ref}] has no definition`, line: block.line });
119
+ }
120
+ }
121
+ for (const [id, block] of definitions) {
122
+ if (!used.has(id)) findings.push({ rule: "footnote-unused", severity: "warning", message: `footnote [^${id}] is not used in the text`, line: block.line });
123
+ }
124
+ return findings;
125
+ }
126
+
127
+ export function getFootnoteDefinitions(blocks: readonly Block[]): Map<string, Block> {
128
+ const definitions = new Map<string, Block>();
129
+ for (const block of blocks) {
130
+ if (block.kind === "footnote" && block.footnoteId !== undefined) definitions.set(block.footnoteId, block);
131
+ }
132
+ return definitions;
133
+ }
134
+
135
+ function daysBetween(fromIso: string, toIso: string): number {
136
+ return Math.round((Date.parse(`${toIso}T00:00:00Z`) - Date.parse(`${fromIso}T00:00:00Z`)) / 86_400_000);
137
+ }
@@ -0,0 +1,137 @@
1
+ // Style rules (FIRE_TRACKER `src/lib/blog/quality/rules-style.ts`): the ruleset's patterns, the app's
2
+ // voice and the YMYL profit promises over prose, then rhythm and formatting measures.
3
+ import { getProseBlocks, type Block } from "../blocks.js";
4
+ import type { QualityFinding } from "../finding.js";
5
+ import type { StylePattern } from "../rulesets/types.js";
6
+ import { countWords, splitSentences, toProse } from "../text.js";
7
+ import type { RuleInput } from "./input.js";
8
+
9
+ const PROFIT_PROMISE_MESSAGE = "a promise of profit or an order to buy; not acceptable in a your-money-or-your-life text";
10
+
11
+ /** The patterns the prose is matched against: ruleset style, app voice, and profit promises when YMYL is on. */
12
+ export function getActivePatterns({ settings }: Pick<RuleInput, "settings">): StylePattern[] {
13
+ const patterns = [...settings.ruleset.patterns, ...settings.voicePatterns];
14
+ if (settings.options.ymyl !== null) {
15
+ patterns.push({ id: "profit-promise", severity: "error", pattern: settings.ruleset.profitPromises, message: PROFIT_PROMISE_MESSAGE });
16
+ }
17
+ return patterns;
18
+ }
19
+
20
+ /** Title, description and summary show in search results and above the text: the error patterns apply to them too. */
21
+ export function checkMetadataStyle(input: RuleInput): QualityFinding[] {
22
+ const { title, description, summary } = input.article;
23
+ const blocks: Block[] = [title, description, ...(summary === null ? [] : [summary])].map((text) => ({ kind: "paragraph", text, line: 1 }));
24
+ return matchPatterns(blocks, getActivePatterns(input).filter((pattern) => pattern.severity === "error")).map((finding) => ({
25
+ rule: finding.rule,
26
+ severity: finding.severity,
27
+ message: `title, description or summary: ${finding.message}`,
28
+ }));
29
+ }
30
+
31
+ export function checkStyle(input: RuleInput): QualityFinding[] {
32
+ return matchPatterns(getProseBlocks(input.blocks), getActivePatterns(input));
33
+ }
34
+
35
+ /** One error per pattern and block; warnings aggregated to one finding per pattern with a count. */
36
+ function matchPatterns(blocks: readonly Block[], patterns: readonly StylePattern[]): QualityFinding[] {
37
+ const findings: QualityFinding[] = [];
38
+ const warningHits = new Map<string, { count: number; line: number; sample: string }>();
39
+ for (const block of blocks) {
40
+ const prose = toProse(block.text);
41
+ for (const style of patterns) {
42
+ const matches = [...prose.matchAll(style.pattern)];
43
+ const first = matches[0]?.[0];
44
+ if (first === undefined) continue;
45
+ if (style.severity === "error") {
46
+ findings.push({ rule: style.id, severity: "error", message: `"${first}": ${style.message}`, line: block.line });
47
+ } else {
48
+ const hit = warningHits.get(style.id) ?? { count: 0, line: block.line, sample: first };
49
+ hit.count += matches.length;
50
+ warningHits.set(style.id, hit);
51
+ }
52
+ }
53
+ }
54
+ for (const style of patterns) {
55
+ const hit = warningHits.get(style.id);
56
+ if (hit !== undefined) {
57
+ findings.push({ rule: style.id, severity: "warning", message: `${String(hit.count)}x (e.g. "${hit.sample}"): ${style.message}`, line: hit.line });
58
+ }
59
+ }
60
+ return findings;
61
+ }
62
+
63
+ const DASH = /\s[—–-]\s|—/g;
64
+
65
+ /** Rhythm and formatting: dashes (an error over the limit), bold, bold labels, triads, sentence length and variety, openings, headings. */
66
+ export function checkRhythm({ blocks, settings }: RuleInput): QualityFinding[] {
67
+ const findings: QualityFinding[] = [];
68
+ const { limits } = settings.options;
69
+ const prose = getProseBlocks(blocks).filter((block) => block.kind !== "heading" && block.kind !== "table");
70
+ const proseText = prose.map((block) => toProse(block.text));
71
+ const totalWords = proseText.reduce((sum, text) => sum + countWords(text), 0);
72
+
73
+ const dashes = proseText.reduce((sum, text) => sum + (text.match(DASH)?.length ?? 0), 0);
74
+ if (dashes >= 3 && dashes > totalWords / limits.wordsPerDash) {
75
+ findings.push({
76
+ rule: "dashes",
77
+ severity: "error",
78
+ message: `${String(dashes)} dashes in ${String(totalWords)} words; at most one per ${String(limits.wordsPerDash)}: use a full stop, a comma or a colon`,
79
+ });
80
+ }
81
+ for (const [index, block] of prose.entries()) {
82
+ if ((proseText[index]?.match(DASH)?.length ?? 0) >= 2) findings.push({ rule: "dashes-paragraph", severity: "warning", message: "two dashes in one paragraph", line: block.line });
83
+ }
84
+
85
+ const bold = blocks.reduce((sum, block) => sum + (block.text.match(/\*\*[^*]+\*\*/g)?.length ?? 0), 0);
86
+ if (bold > 0 && bold > totalWords / limits.wordsPerBold) {
87
+ findings.push({ rule: "bold-density", severity: "warning", message: `${String(bold)} bold phrases in ${String(totalWords)} words; bold stops standing out` });
88
+ }
89
+ const boldLabels = blocks.filter((block) => block.kind === "listItem" && /^\*\*[^*]+:\*\*|^\*\*[^*]+\*\*:/.test(block.text));
90
+ const firstLabel = boldLabels[0];
91
+ if (firstLabel !== undefined && boldLabels.length >= 3) {
92
+ findings.push({
93
+ rule: "bold-labels",
94
+ severity: "warning",
95
+ message: `${String(boldLabels.length)} list items start with a bold label; the "**Label:** text" template`,
96
+ line: firstLabel.line,
97
+ });
98
+ }
99
+
100
+ const triads = proseText.reduce((sum, text) => sum + (text.match(settings.ruleset.triad)?.length ?? 0), 0);
101
+ if (triads > 3) findings.push({ rule: "triads", severity: "warning", message: `${String(triads)} lists of three; two items sound less like a template than three` });
102
+
103
+ const sentences = proseText.flatMap((text) => splitSentences(text));
104
+ const lengths = sentences.map((sentence) => countWords(sentence));
105
+ const long = lengths.filter((length) => length > limits.sentenceWords).length;
106
+ if (long > 0) findings.push({ rule: "long-sentences", severity: "warning", message: `${String(long)} sentences longer than ${String(limits.sentenceWords)} words` });
107
+ if (lengths.length >= 12) {
108
+ const mean = lengths.reduce((sum, length) => sum + length, 0) / lengths.length;
109
+ const deviation = Math.sqrt(lengths.reduce((sum, length) => sum + (length - mean) ** 2, 0) / lengths.length);
110
+ if (deviation < 4) {
111
+ findings.push({
112
+ rule: "monotone-rhythm",
113
+ severity: "warning",
114
+ message: `the sentences have similar lengths (mean ${mean.toFixed(0)} words, deviation ${deviation.toFixed(1)}); mix short ones with long ones`,
115
+ });
116
+ }
117
+ }
118
+ for (let index = 2; index < sentences.length; index += 1) {
119
+ const openings = sentences.slice(index - 2, index + 1).map((sentence) => sentence.split(" ")[0]?.toLowerCase());
120
+ const opening = openings[0];
121
+ if (opening !== undefined && openings.every((other) => other === opening)) {
122
+ findings.push({ rule: "repeated-openings", severity: "warning", message: `three sentences in a row start with "${opening}"` });
123
+ break;
124
+ }
125
+ }
126
+
127
+ if (settings.ruleset.flagsTitleCaseHeadings) {
128
+ for (const block of blocks) {
129
+ if (block.kind !== "heading") continue;
130
+ const words = block.text.split(/\s+/).filter((word) => /^\p{L}/u.test(word));
131
+ if (words.length >= 3 && words.every((word) => /^\p{Lu}/u.test(word))) {
132
+ findings.push({ rule: "title-case-heading", severity: "warning", message: `heading "${block.text}": only the first word starts with a capital letter`, line: block.line });
133
+ }
134
+ }
135
+ }
136
+ return findings;
137
+ }
@@ -0,0 +1,57 @@
1
+ // YMYL rules (FIRE_TRACKER `rules-structure.ts`, sources and `checkNumberSources`): an article lists
2
+ // its sources over https, and every significant number carries a footnote whose address is a listed
3
+ // source, or which names the app's own calculation (`ymyl.ownCalculationMark`).
4
+ import type { Block } from "../blocks.js";
5
+ import type { QualityFinding } from "../finding.js";
6
+ import { findBareUrls, findFootnoteRefs, findSignificantNumbers, toProse } from "../text.js";
7
+ import type { RuleInput } from "./input.js";
8
+ import { getFootnoteDefinitions } from "./structure.js";
9
+
10
+ export function checkSources({ article }: RuleInput): QualityFinding[] {
11
+ const findings: QualityFinding[] = [];
12
+ if (article.kind === "article" && article.sources.length === 0) {
13
+ findings.push({ rule: "sources-missing", severity: "error", message: "the article lists no sources (name and address of each)" });
14
+ }
15
+ for (const source of article.sources) {
16
+ if (!source.url.startsWith("https://")) {
17
+ findings.push({ rule: "source-https", severity: "error", message: `source "${source.name}" has an address without https: ${source.url}` });
18
+ }
19
+ }
20
+ return findings;
21
+ }
22
+
23
+ export function checkNumberSources({ article, blocks, settings }: RuleInput): QualityFinding[] {
24
+ const findings: QualityFinding[] = [];
25
+ const { notation } = settings.ruleset;
26
+ const mark = settings.options.ymyl?.ownCalculationMark ?? null;
27
+ for (const block of blocks) {
28
+ if (block.kind === "footnote" || block.kind === "directive" || block.kind === "heading") continue;
29
+ const numbers = findSignificantNumbers(toProse(block.text), notation);
30
+ if (numbers.length > 0 && findFootnoteRefs(block.text).length === 0 && !isTableSourcedNearby(blocks, block)) {
31
+ const remedy = mark === null ? "add a footnote with the source's address" : `add a footnote with the source's address or "${mark}"`;
32
+ findings.push({ rule: "number-source", severity: "error", message: `a number without a source: ${numbers.slice(0, 3).join(", ")}; ${remedy}`, line: block.line });
33
+ }
34
+ }
35
+ const sourceUrls = new Set(article.sources.map((source) => source.url));
36
+ for (const [id, block] of getFootnoteDefinitions(blocks)) {
37
+ const urls = findBareUrls(block.text);
38
+ const isOwnCalculation = mark !== null && block.text.toLowerCase().includes(mark.toLowerCase());
39
+ if (urls.length === 0 && !isOwnCalculation) {
40
+ const message = mark === null ? `footnote [^${id}] has no source address` : `footnote [^${id}] has neither a source address nor "${mark}"`;
41
+ findings.push({ rule: "footnote-source", severity: "error", message, line: block.line });
42
+ }
43
+ for (const url of urls) {
44
+ if (!sourceUrls.has(url)) {
45
+ findings.push({ rule: "footnote-not-in-sources", severity: "error", message: `the address in footnote [^${id}] is not in sources: ${url}`, line: block.line });
46
+ }
47
+ }
48
+ }
49
+ return findings;
50
+ }
51
+
52
+ /** A table has no room for a footnote per cell, so the paragraph right above or below may carry it. */
53
+ function isTableSourcedNearby(blocks: readonly Block[], table: Block): boolean {
54
+ if (table.kind !== "table") return false;
55
+ const index = blocks.indexOf(table);
56
+ return [blocks[index - 1], blocks[index + 1]].some((neighbour) => neighbour?.kind === "paragraph" && findFootnoteRefs(neighbour.text).length > 0);
57
+ }
@@ -0,0 +1,117 @@
1
+ // The English ruleset: the same habits as the Polish one, in English, after `blader/humanizer` and
2
+ // `hardikpandya/stop-slop`. Errors are habits a careful author uses rarely and on purpose; warnings
3
+ // are words that say nothing alone but betray a template in a cluster.
4
+ import { wordPattern } from "../../text.js";
5
+ import type { LanguageRuleset } from "../types.js";
6
+
7
+ export const enRuleset: LanguageRuleset = {
8
+ language: "en",
9
+ patterns: [
10
+ {
11
+ id: "announcement",
12
+ severity: "error",
13
+ pattern: wordPattern("it(?:'s| is) (?:worth|important to) (?:noting|note|mentioning|mention|pointing out|highlighting|emphasizing)|it should be noted|needless to say"),
14
+ message: "an announcement instead of a fact; write the fact",
15
+ },
16
+ {
17
+ id: "these-days",
18
+ severity: "error",
19
+ pattern: wordPattern("in today's (?:fast-paced |ever-changing |digital |modern )?(?:world|age|era|landscape)|in this day and age|in the modern era|now more than ever"),
20
+ message: "an empty opener about \"these times\"; start with a number or the answer",
21
+ },
22
+ {
23
+ id: "not-only-but-also",
24
+ severity: "error",
25
+ pattern: wordPattern("not only[^.!?]{1,120}? but also"),
26
+ message: "\"not only…, but also\"; name both things plainly",
27
+ },
28
+ {
29
+ id: "not-x-but-y",
30
+ severity: "error",
31
+ pattern: wordPattern("it(?:'s| is) not (?:just |only |about )?[^.!?,;]{1,60}[,;] it(?:'s| is)|this isn't (?:just )?[^.!?,;]{1,60}[,;] it's"),
32
+ message: "the \"it is not X, it is Y\" contrast; say Y right away",
33
+ },
34
+ {
35
+ id: "meta-commentary",
36
+ severity: "error",
37
+ pattern: wordPattern(
38
+ "in this (?:article|post|guide|piece|blog post)|let's (?:dive|delve|take a (?:closer )?look|explore|break (?:it|this) down)|without further ado|as (?:we )?(?:mentioned|discussed) (?:earlier|above|before)|as you'll see|you'll learn|read on to",
39
+ ),
40
+ message: "the text talks about itself instead of the topic; remove the announcement",
41
+ },
42
+ {
43
+ id: "throat-clearing",
44
+ severity: "error",
45
+ pattern: wordPattern(
46
+ "the truth is|here's (?:why|how|what|the thing)|here's where|interestingly|importantly|undoubtedly|without a doubt|it goes without saying|make no mistake|the reality is",
47
+ ),
48
+ message: "a run-up before the point; cut it and start with the point",
49
+ },
50
+ {
51
+ id: "empty-conclusion",
52
+ severity: "error",
53
+ pattern: wordPattern("in conclusion|to sum up|to summarize|in summary|all in all|at the end of the day|the bottom line is"),
54
+ message: "an empty conclusion; end with the last concrete fact",
55
+ },
56
+ {
57
+ id: "crucial",
58
+ severity: "error",
59
+ pattern: wordPattern("crucial|pivotal|delve|delves|delving|tapestry|testament to"),
60
+ message: "a favourite word of language models; name what depends on the thing",
61
+ },
62
+ {
63
+ id: "plays-a-role",
64
+ severity: "error",
65
+ pattern: wordPattern("plays? an? (?:\\p{L}+ )?role|playing an? (?:\\p{L}+ )?role"),
66
+ message: "\"plays a role\"; write what the thing does",
67
+ },
68
+ {
69
+ id: "puffery",
70
+ severity: "error",
71
+ pattern: wordPattern(
72
+ "game[- ]changer|groundbreaking|revolutionary|cutting-edge|unparalleled|invaluable|holistic|comprehensive guide|ultimate guide|a wide (?:range|array|variety) of|navigat(?:e|ing) the (?:complex |ever-changing )?(?:world|landscape|maze) of|ever-evolving|ever-changing landscape|seamless(?:ly)?|there(?:'s| is) no one-size-fits-all|depends on many factors",
73
+ ),
74
+ message: "an inflated or empty phrase; replace it with a fact",
75
+ },
76
+ {
77
+ id: "chatbot-phrases",
78
+ severity: "error",
79
+ pattern: wordPattern("I hope this helps|let me know if|feel free to|great question|as an AI(?: language model)?|as of my (?:last|knowledge) (?:update|cutoff)|happy to help|I'd be happy to"),
80
+ message: "a leftover of a chat with an assistant; remove it",
81
+ },
82
+ {
83
+ id: "emoji",
84
+ severity: "error",
85
+ pattern: /\p{Extended_Pictographic}/gu,
86
+ message: "an emoji in the text; remove it",
87
+ },
88
+ {
89
+ id: "filler-words",
90
+ severity: "warning",
91
+ pattern: wordPattern(
92
+ "moreover|furthermore|additionally|in addition|in other words|simply put|very|really|truly|absolutely|definitely|literally|basically|essentially|actually|just|optimal|leverage|robust|remember that",
93
+ ),
94
+ message: "a filler word; check whether the sentence says the same without it",
95
+ },
96
+ {
97
+ id: "exclamation",
98
+ severity: "warning",
99
+ pattern: /!/g,
100
+ message: "an exclamation mark; a guide speaks calmly",
101
+ },
102
+ ],
103
+ firstPersonSingular: wordPattern("I|I'm|I've|I'd|I'll|me|my|mine|myself|in my (?:opinion|view|experience)|personally"),
104
+ profitPromises: wordPattern(
105
+ "guaranteed (?:returns?|profits?|income|gains?)|(?:a )?sure (?:profit|bet|thing)|(?<!not |never |isn't |aren't )risk[- ]free|zero risk|no risk|(?:you )?can(?:'t|not) lose|you will (?:definitely |surely )?(?:profit|earn|get rich)|buy (?:now|today)|you (?:must|need to|have to) (?:buy|invest)|get rich quick|quick (?:profits?|riches)|easy money|effortless (?:income|returns?|wealth)",
106
+ ),
107
+ notation: {
108
+ number: /\d{1,3}(?:,\d{3})+(?:\.\d+)?|\d+(?:\.\d+)?/g,
109
+ unitAfter: /^\s?(?:%|percent\b|per cent\b|USD\b|EUR\b|GBP\b|dollars?\b|euros?\b|pounds?\b|k\b|thousand\b|million\b|billion\b|bn\b)/iu,
110
+ unitBefore: /(?:[$€£]|USD ?|EUR ?|GBP ?)$/u,
111
+ referenceBefore: /(?:§|section|sec\.|art\.|article|no\.|chapter|clause|rule|part)\s*$/iu,
112
+ thousandsSeparator: /,/,
113
+ decimalSeparator: ".",
114
+ },
115
+ triad: /(?<![\p{L}])\p{L}+, \p{L}+,? (?:and|or) \p{L}+(?![\p{L}])/gu,
116
+ flagsTitleCaseHeadings: false,
117
+ };
@@ -0,0 +1,9 @@
1
+ // The language rulesets an app chooses from with `blog({ quality: { language } })`.
2
+ import { enRuleset } from "./en/ruleset.js";
3
+ import { plRuleset } from "./pl/ruleset.js";
4
+ import type { LanguageRuleset, QualityLanguage } from "./types.js";
5
+
6
+ export const QUALITY_RULESETS: Readonly<Record<QualityLanguage, LanguageRuleset>> = { en: enRuleset, pl: plRuleset };
7
+
8
+ export { enRuleset, plRuleset };
9
+ export { QUALITY_LANGUAGES, type LanguageRuleset, type QualityLanguage, type StylePattern } from "./types.js";