@softure-ai/blog 0.0.0-stage → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (419) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +604 -2
  4. package/dist/cli/bin.d.ts +3 -0
  5. package/dist/cli/bin.d.ts.map +1 -0
  6. package/dist/cli/bin.js +5 -0
  7. package/dist/cli/bin.js.map +1 -0
  8. package/dist/cli/command.d.ts +9 -0
  9. package/dist/cli/command.d.ts.map +1 -0
  10. package/dist/cli/command.js +40 -0
  11. package/dist/cli/command.js.map +1 -0
  12. package/dist/cli/index.d.ts +4 -0
  13. package/dist/cli/index.d.ts.map +1 -0
  14. package/dist/cli/index.js +5 -0
  15. package/dist/cli/index.js.map +1 -0
  16. package/dist/cli/run.d.ts +62 -0
  17. package/dist/cli/run.d.ts.map +1 -0
  18. package/dist/cli/run.js +555 -0
  19. package/dist/cli/run.js.map +1 -0
  20. package/dist/cli/skill.d.ts +47 -0
  21. package/dist/cli/skill.d.ts.map +1 -0
  22. package/dist/cli/skill.js +221 -0
  23. package/dist/cli/skill.js.map +1 -0
  24. package/dist/content/article-file.d.ts +27 -0
  25. package/dist/content/article-file.d.ts.map +1 -0
  26. package/dist/content/article-file.js +179 -0
  27. package/dist/content/article-file.js.map +1 -0
  28. package/dist/contract.d.ts +77 -0
  29. package/dist/contract.d.ts.map +1 -0
  30. package/dist/contract.js +5 -0
  31. package/dist/contract.js.map +1 -0
  32. package/dist/db/articles.d.ts +30 -0
  33. package/dist/db/articles.d.ts.map +1 -0
  34. package/dist/db/articles.js +138 -0
  35. package/dist/db/articles.js.map +1 -0
  36. package/dist/db/publish-run.d.ts +50 -0
  37. package/dist/db/publish-run.d.ts.map +1 -0
  38. package/dist/db/publish-run.js +168 -0
  39. package/dist/db/publish-run.js.map +1 -0
  40. package/dist/db/schema.d.ts +403 -0
  41. package/dist/db/schema.d.ts.map +1 -0
  42. package/dist/db/schema.js +33 -0
  43. package/dist/db/schema.js.map +1 -0
  44. package/dist/discovery/dates.d.ts +9 -0
  45. package/dist/discovery/dates.d.ts.map +1 -0
  46. package/dist/discovery/dates.js +19 -0
  47. package/dist/discovery/dates.js.map +1 -0
  48. package/dist/discovery/index.d.ts +8 -0
  49. package/dist/discovery/index.d.ts.map +1 -0
  50. package/dist/discovery/index.js +11 -0
  51. package/dist/discovery/index.js.map +1 -0
  52. package/dist/discovery/indexnow.d.ts +11 -0
  53. package/dist/discovery/indexnow.d.ts.map +1 -0
  54. package/dist/discovery/indexnow.js +23 -0
  55. package/dist/discovery/indexnow.js.map +1 -0
  56. package/dist/discovery/refresh.d.ts +49 -0
  57. package/dist/discovery/refresh.d.ts.map +1 -0
  58. package/dist/discovery/refresh.js +55 -0
  59. package/dist/discovery/refresh.js.map +1 -0
  60. package/dist/discovery/related.d.ts +17 -0
  61. package/dist/discovery/related.d.ts.map +1 -0
  62. package/dist/discovery/related.js +28 -0
  63. package/dist/discovery/related.js.map +1 -0
  64. package/dist/discovery/rss.d.ts +30 -0
  65. package/dist/discovery/rss.d.ts.map +1 -0
  66. package/dist/discovery/rss.js +48 -0
  67. package/dist/discovery/rss.js.map +1 -0
  68. package/dist/discovery/sitemap.d.ts +25 -0
  69. package/dist/discovery/sitemap.d.ts.map +1 -0
  70. package/dist/discovery/sitemap.js +31 -0
  71. package/dist/discovery/sitemap.js.map +1 -0
  72. package/dist/discovery/submit.d.ts +44 -0
  73. package/dist/discovery/submit.d.ts.map +1 -0
  74. package/dist/discovery/submit.js +46 -0
  75. package/dist/discovery/submit.js.map +1 -0
  76. package/dist/index.d.ts +371 -0
  77. package/dist/index.d.ts.map +1 -0
  78. package/dist/index.js +69 -0
  79. package/dist/index.js.map +1 -0
  80. package/dist/messages/en.d.ts +83 -0
  81. package/dist/messages/en.d.ts.map +1 -0
  82. package/dist/messages/en.js +83 -0
  83. package/dist/messages/en.js.map +1 -0
  84. package/dist/messages/index.d.ts +158 -0
  85. package/dist/messages/index.d.ts.map +1 -0
  86. package/dist/messages/index.js +5 -0
  87. package/dist/messages/index.js.map +1 -0
  88. package/dist/messages/pl.d.ts +78 -0
  89. package/dist/messages/pl.d.ts.map +1 -0
  90. package/dist/messages/pl.js +78 -0
  91. package/dist/messages/pl.js.map +1 -0
  92. package/dist/next/context.d.ts +6 -0
  93. package/dist/next/context.d.ts.map +1 -0
  94. package/dist/next/context.js +27 -0
  95. package/dist/next/context.js.map +1 -0
  96. package/dist/next/data.d.ts +11 -0
  97. package/dist/next/data.d.ts.map +1 -0
  98. package/dist/next/data.js +40 -0
  99. package/dist/next/data.js.map +1 -0
  100. package/dist/next/discovery.d.ts +6 -0
  101. package/dist/next/discovery.d.ts.map +1 -0
  102. package/dist/next/discovery.js +49 -0
  103. package/dist/next/discovery.js.map +1 -0
  104. package/dist/next/index.d.ts +9 -0
  105. package/dist/next/index.d.ts.map +1 -0
  106. package/dist/next/index.js +10 -0
  107. package/dist/next/index.js.map +1 -0
  108. package/dist/next/og-fonts.d.ts +6 -0
  109. package/dist/next/og-fonts.d.ts.map +1 -0
  110. package/dist/next/og-fonts.js +8 -0
  111. package/dist/next/og-fonts.js.map +1 -0
  112. package/dist/next/og-image.d.ts +36 -0
  113. package/dist/next/og-image.d.ts.map +1 -0
  114. package/dist/next/og-image.js +77 -0
  115. package/dist/next/og-image.js.map +1 -0
  116. package/dist/next/pages.d.ts +45 -0
  117. package/dist/next/pages.d.ts.map +1 -0
  118. package/dist/next/pages.js +189 -0
  119. package/dist/next/pages.js.map +1 -0
  120. package/dist/next/refresh.d.ts +9 -0
  121. package/dist/next/refresh.d.ts.map +1 -0
  122. package/dist/next/refresh.js +81 -0
  123. package/dist/next/refresh.js.map +1 -0
  124. package/dist/options.d.ts +320 -0
  125. package/dist/options.d.ts.map +1 -0
  126. package/dist/options.js +184 -0
  127. package/dist/options.js.map +1 -0
  128. package/dist/pages/body.d.ts +18 -0
  129. package/dist/pages/body.d.ts.map +1 -0
  130. package/dist/pages/body.js +21 -0
  131. package/dist/pages/body.js.map +1 -0
  132. package/dist/pages/dates.d.ts +21 -0
  133. package/dist/pages/dates.d.ts.map +1 -0
  134. package/dist/pages/dates.js +24 -0
  135. package/dist/pages/dates.js.map +1 -0
  136. package/dist/pages/index.d.ts +7 -0
  137. package/dist/pages/index.d.ts.map +1 -0
  138. package/dist/pages/index.js +9 -0
  139. package/dist/pages/index.js.map +1 -0
  140. package/dist/pages/json-ld.d.ts +33 -0
  141. package/dist/pages/json-ld.d.ts.map +1 -0
  142. package/dist/pages/json-ld.js +95 -0
  143. package/dist/pages/json-ld.js.map +1 -0
  144. package/dist/pages/listing.d.ts +46 -0
  145. package/dist/pages/listing.d.ts.map +1 -0
  146. package/dist/pages/listing.js +57 -0
  147. package/dist/pages/listing.js.map +1 -0
  148. package/dist/pages/paths.d.ts +37 -0
  149. package/dist/pages/paths.d.ts.map +1 -0
  150. package/dist/pages/paths.js +61 -0
  151. package/dist/pages/paths.js.map +1 -0
  152. package/dist/pages/redirects.d.ts +57 -0
  153. package/dist/pages/redirects.d.ts.map +1 -0
  154. package/dist/pages/redirects.js +78 -0
  155. package/dist/pages/redirects.js.map +1 -0
  156. package/dist/proxy/index.d.ts +15 -0
  157. package/dist/proxy/index.d.ts.map +1 -0
  158. package/dist/proxy/index.js +60 -0
  159. package/dist/proxy/index.js.map +1 -0
  160. package/dist/quality/blocks.d.ts +21 -0
  161. package/dist/quality/blocks.d.ts.map +1 -0
  162. package/dist/quality/blocks.js +113 -0
  163. package/dist/quality/blocks.js.map +1 -0
  164. package/dist/quality/catalog.d.ts +10 -0
  165. package/dist/quality/catalog.d.ts.map +1 -0
  166. package/dist/quality/catalog.js +71 -0
  167. package/dist/quality/catalog.js.map +1 -0
  168. package/dist/quality/check-article.d.ts +34 -0
  169. package/dist/quality/check-article.d.ts.map +1 -0
  170. package/dist/quality/check-article.js +74 -0
  171. package/dist/quality/check-article.js.map +1 -0
  172. package/dist/quality/check-files.d.ts +23 -0
  173. package/dist/quality/check-files.d.ts.map +1 -0
  174. package/dist/quality/check-files.js +44 -0
  175. package/dist/quality/check-files.js.map +1 -0
  176. package/dist/quality/external-links.d.ts +19 -0
  177. package/dist/quality/external-links.d.ts.map +1 -0
  178. package/dist/quality/external-links.js +30 -0
  179. package/dist/quality/external-links.js.map +1 -0
  180. package/dist/quality/finding.d.ts +17 -0
  181. package/dist/quality/finding.d.ts.map +1 -0
  182. package/dist/quality/finding.js +15 -0
  183. package/dist/quality/finding.js.map +1 -0
  184. package/dist/quality/gate.d.ts +5 -0
  185. package/dist/quality/gate.d.ts.map +1 -0
  186. package/dist/quality/gate.js +10 -0
  187. package/dist/quality/gate.js.map +1 -0
  188. package/dist/quality/index.d.ts +15 -0
  189. package/dist/quality/index.d.ts.map +1 -0
  190. package/dist/quality/index.js +16 -0
  191. package/dist/quality/index.js.map +1 -0
  192. package/dist/quality/link-targets.d.ts +44 -0
  193. package/dist/quality/link-targets.d.ts.map +1 -0
  194. package/dist/quality/link-targets.js +91 -0
  195. package/dist/quality/link-targets.js.map +1 -0
  196. package/dist/quality/options.d.ts +113 -0
  197. package/dist/quality/options.d.ts.map +1 -0
  198. package/dist/quality/options.js +93 -0
  199. package/dist/quality/options.js.map +1 -0
  200. package/dist/quality/plugin.d.ts +34 -0
  201. package/dist/quality/plugin.d.ts.map +1 -0
  202. package/dist/quality/plugin.js +7 -0
  203. package/dist/quality/plugin.js.map +1 -0
  204. package/dist/quality/rules/blocks.d.ts +5 -0
  205. package/dist/quality/rules/blocks.d.ts.map +1 -0
  206. package/dist/quality/rules/blocks.js +35 -0
  207. package/dist/quality/rules/blocks.js.map +1 -0
  208. package/dist/quality/rules/images.d.ts +4 -0
  209. package/dist/quality/rules/images.d.ts.map +1 -0
  210. package/dist/quality/rules/images.js +33 -0
  211. package/dist/quality/rules/images.js.map +1 -0
  212. package/dist/quality/rules/input.d.ts +11 -0
  213. package/dist/quality/rules/input.d.ts.map +1 -0
  214. package/dist/quality/rules/input.js +2 -0
  215. package/dist/quality/rules/input.js.map +1 -0
  216. package/dist/quality/rules/links.d.ts +17 -0
  217. package/dist/quality/rules/links.d.ts.map +1 -0
  218. package/dist/quality/rules/links.js +46 -0
  219. package/dist/quality/rules/links.js.map +1 -0
  220. package/dist/quality/rules/structure.d.ts +13 -0
  221. package/dist/quality/rules/structure.d.ts.map +1 -0
  222. package/dist/quality/rules/structure.js +139 -0
  223. package/dist/quality/rules/structure.js.map +1 -0
  224. package/dist/quality/rules/style.d.ts +11 -0
  225. package/dist/quality/rules/style.d.ts.map +1 -0
  226. package/dist/quality/rules/style.js +129 -0
  227. package/dist/quality/rules/style.js.map +1 -0
  228. package/dist/quality/rules/ymyl.d.ts +5 -0
  229. package/dist/quality/rules/ymyl.d.ts.map +1 -0
  230. package/dist/quality/rules/ymyl.js +51 -0
  231. package/dist/quality/rules/ymyl.js.map +1 -0
  232. package/dist/quality/rulesets/en/ruleset.d.ts +3 -0
  233. package/dist/quality/rulesets/en/ruleset.d.ts.map +1 -0
  234. package/dist/quality/rulesets/en/ruleset.js +106 -0
  235. package/dist/quality/rulesets/en/ruleset.js.map +1 -0
  236. package/dist/quality/rulesets/index.d.ts +7 -0
  237. package/dist/quality/rulesets/index.d.ts.map +1 -0
  238. package/dist/quality/rulesets/index.js +7 -0
  239. package/dist/quality/rulesets/index.js.map +1 -0
  240. package/dist/quality/rulesets/pl/ruleset.d.ts +3 -0
  241. package/dist/quality/rulesets/pl/ruleset.d.ts.map +1 -0
  242. package/dist/quality/rulesets/pl/ruleset.js +114 -0
  243. package/dist/quality/rulesets/pl/ruleset.js.map +1 -0
  244. package/dist/quality/rulesets/types.d.ts +28 -0
  245. package/dist/quality/rulesets/types.d.ts.map +1 -0
  246. package/dist/quality/rulesets/types.js +2 -0
  247. package/dist/quality/rulesets/types.js.map +1 -0
  248. package/dist/quality/settings.d.ts +26 -0
  249. package/dist/quality/settings.d.ts.map +1 -0
  250. package/dist/quality/settings.js +32 -0
  251. package/dist/quality/settings.js.map +1 -0
  252. package/dist/quality/text.d.ts +43 -0
  253. package/dist/quality/text.d.ts.map +1 -0
  254. package/dist/quality/text.js +85 -0
  255. package/dist/quality/text.js.map +1 -0
  256. package/dist/render/glossary.d.ts +27 -0
  257. package/dist/render/glossary.d.ts.map +1 -0
  258. package/dist/render/glossary.js +71 -0
  259. package/dist/render/glossary.js.map +1 -0
  260. package/dist/render/images.d.ts +42 -0
  261. package/dist/render/images.d.ts.map +1 -0
  262. package/dist/render/images.js +84 -0
  263. package/dist/render/images.js.map +1 -0
  264. package/dist/render/index.d.ts +6 -0
  265. package/dist/render/index.d.ts.map +1 -0
  266. package/dist/render/index.js +8 -0
  267. package/dist/render/index.js.map +1 -0
  268. package/dist/render/reading-time.d.ts +8 -0
  269. package/dist/render/reading-time.d.ts.map +1 -0
  270. package/dist/render/reading-time.js +17 -0
  271. package/dist/render/reading-time.js.map +1 -0
  272. package/dist/render/render-article.d.ts +97 -0
  273. package/dist/render/render-article.d.ts.map +1 -0
  274. package/dist/render/render-article.js +373 -0
  275. package/dist/render/render-article.js.map +1 -0
  276. package/dist/render/slugify-heading.d.ts +2 -0
  277. package/dist/render/slugify-heading.d.ts.map +1 -0
  278. package/dist/render/slugify-heading.js +21 -0
  279. package/dist/render/slugify-heading.js.map +1 -0
  280. package/dist/server/health.d.ts +3 -0
  281. package/dist/server/health.d.ts.map +1 -0
  282. package/dist/server/health.js +11 -0
  283. package/dist/server/health.js.map +1 -0
  284. package/dist/server/index.d.ts +10 -0
  285. package/dist/server/index.d.ts.map +1 -0
  286. package/dist/server/index.js +13 -0
  287. package/dist/server/index.js.map +1 -0
  288. package/dist/server/og-fonts.d.ts +24 -0
  289. package/dist/server/og-fonts.d.ts.map +1 -0
  290. package/dist/server/og-fonts.js +85 -0
  291. package/dist/server/og-fonts.js.map +1 -0
  292. package/dist/server/options.d.ts +17 -0
  293. package/dist/server/options.d.ts.map +1 -0
  294. package/dist/server/options.js +56 -0
  295. package/dist/server/options.js.map +1 -0
  296. package/dist/sitemap.d.ts +11 -0
  297. package/dist/sitemap.d.ts.map +1 -0
  298. package/dist/sitemap.js +35 -0
  299. package/dist/sitemap.js.map +1 -0
  300. package/dist/ui/blog-article.d.ts +49 -0
  301. package/dist/ui/blog-article.d.ts.map +1 -0
  302. package/dist/ui/blog-article.js +45 -0
  303. package/dist/ui/blog-article.js.map +1 -0
  304. package/dist/ui/blog-glossary.d.ts +27 -0
  305. package/dist/ui/blog-glossary.d.ts.map +1 -0
  306. package/dist/ui/blog-glossary.js +20 -0
  307. package/dist/ui/blog-glossary.js.map +1 -0
  308. package/dist/ui/blog-layout.d.ts +31 -0
  309. package/dist/ui/blog-layout.d.ts.map +1 -0
  310. package/dist/ui/blog-layout.js +25 -0
  311. package/dist/ui/blog-layout.js.map +1 -0
  312. package/dist/ui/blog-listing.d.ts +15 -0
  313. package/dist/ui/blog-listing.d.ts.map +1 -0
  314. package/dist/ui/blog-listing.js +29 -0
  315. package/dist/ui/blog-listing.js.map +1 -0
  316. package/dist/ui/blog-method.d.ts +5 -0
  317. package/dist/ui/blog-method.d.ts.map +1 -0
  318. package/dist/ui/blog-method.js +21 -0
  319. package/dist/ui/blog-method.js.map +1 -0
  320. package/dist/ui/index.d.ts +7 -0
  321. package/dist/ui/index.d.ts.map +1 -0
  322. package/dist/ui/index.js +8 -0
  323. package/dist/ui/index.js.map +1 -0
  324. package/dist/ui/page-context.d.ts +15 -0
  325. package/dist/ui/page-context.d.ts.map +1 -0
  326. package/dist/ui/page-context.js +2 -0
  327. package/dist/ui/page-context.js.map +1 -0
  328. package/migrations/0001_create_articles.sql +67 -0
  329. package/migrations/README.md +8 -0
  330. package/module.json +28 -0
  331. package/package.json +102 -4
  332. package/skill/SKILL.md +72 -0
  333. package/skill/references/reviewer.md +39 -0
  334. package/skill/references/rules.md +122 -0
  335. package/skill/references/structure.md +69 -0
  336. package/skill/references/template.md +86 -0
  337. package/src/cli/bin.ts +5 -0
  338. package/src/cli/command.ts +49 -0
  339. package/src/cli/index.ts +20 -0
  340. package/src/cli/run.ts +591 -0
  341. package/src/cli/skill.ts +242 -0
  342. package/src/content/article-file.ts +184 -0
  343. package/src/contract.ts +88 -0
  344. package/src/db/articles.ts +160 -0
  345. package/src/db/publish-run.ts +224 -0
  346. package/src/db/schema.ts +36 -0
  347. package/src/discovery/dates.ts +27 -0
  348. package/src/discovery/index.ts +18 -0
  349. package/src/discovery/indexnow.ts +25 -0
  350. package/src/discovery/refresh.ts +79 -0
  351. package/src/discovery/related.ts +35 -0
  352. package/src/discovery/rss.ts +77 -0
  353. package/src/discovery/sitemap.ts +59 -0
  354. package/src/discovery/submit.ts +69 -0
  355. package/src/index.ts +99 -0
  356. package/src/messages/en.ts +82 -0
  357. package/src/messages/index.ts +7 -0
  358. package/src/messages/pl.ts +77 -0
  359. package/src/next/context.ts +30 -0
  360. package/src/next/data.ts +55 -0
  361. package/src/next/discovery.ts +49 -0
  362. package/src/next/index.ts +25 -0
  363. package/src/next/next-modules.d.ts +15 -0
  364. package/src/next/og-fonts.ts +14 -0
  365. package/src/next/og-image.tsx +108 -0
  366. package/src/next/pages.tsx +251 -0
  367. package/src/next/refresh.ts +85 -0
  368. package/src/options.ts +224 -0
  369. package/src/pages/body.ts +38 -0
  370. package/src/pages/dates.ts +39 -0
  371. package/src/pages/index.ts +37 -0
  372. package/src/pages/json-ld.ts +120 -0
  373. package/src/pages/listing.ts +95 -0
  374. package/src/pages/paths.ts +84 -0
  375. package/src/pages/redirects.ts +120 -0
  376. package/src/proxy/index.ts +66 -0
  377. package/src/quality/blocks.ts +130 -0
  378. package/src/quality/catalog.ts +86 -0
  379. package/src/quality/check-article.ts +112 -0
  380. package/src/quality/check-files.ts +63 -0
  381. package/src/quality/external-links.ts +37 -0
  382. package/src/quality/finding.ts +29 -0
  383. package/src/quality/gate.ts +15 -0
  384. package/src/quality/index.ts +28 -0
  385. package/src/quality/link-targets.ts +107 -0
  386. package/src/quality/options.ts +105 -0
  387. package/src/quality/plugin.ts +43 -0
  388. package/src/quality/rules/blocks.ts +43 -0
  389. package/src/quality/rules/images.ts +34 -0
  390. package/src/quality/rules/input.ts +12 -0
  391. package/src/quality/rules/links.ts +56 -0
  392. package/src/quality/rules/structure.ts +137 -0
  393. package/src/quality/rules/style.ts +137 -0
  394. package/src/quality/rules/ymyl.ts +57 -0
  395. package/src/quality/rulesets/en/ruleset.ts +117 -0
  396. package/src/quality/rulesets/index.ts +9 -0
  397. package/src/quality/rulesets/pl/ruleset.ts +126 -0
  398. package/src/quality/rulesets/types.ts +31 -0
  399. package/src/quality/settings.ts +60 -0
  400. package/src/quality/text.ts +113 -0
  401. package/src/render/glossary.ts +103 -0
  402. package/src/render/images.ts +110 -0
  403. package/src/render/index.ts +29 -0
  404. package/src/render/reading-time.ts +18 -0
  405. package/src/render/render-article.ts +487 -0
  406. package/src/render/slugify-heading.ts +22 -0
  407. package/src/server/health.ts +12 -0
  408. package/src/server/index.ts +28 -0
  409. package/src/server/og-fonts.ts +102 -0
  410. package/src/server/options.ts +62 -0
  411. package/src/sitemap.ts +36 -0
  412. package/src/ui/blog-article.tsx +186 -0
  413. package/src/ui/blog-glossary.tsx +104 -0
  414. package/src/ui/blog-layout.tsx +89 -0
  415. package/src/ui/blog-listing.tsx +95 -0
  416. package/src/ui/blog-method.tsx +34 -0
  417. package/src/ui/index.ts +8 -0
  418. package/src/ui/page-context.ts +17 -0
  419. package/styles.css +498 -0
@@ -0,0 +1,126 @@
1
+ // The Polish ruleset (FIRE_TRACKER `src/lib/blog/quality/rules-style.ts`, `text.ts`). A translation and
2
+ // extension of `blader/humanizer` and `hardikpandya/stop-slop`. Errors are habits a careful author uses
3
+ // rarely and on purpose; warnings are words that say nothing alone but betray a template in a cluster.
4
+ // FIRE's voice (no first person singular) and finance phrases are app config, not Polish.
5
+ import { wordPattern } from "../../text.js";
6
+ import type { LanguageRuleset } from "../types.js";
7
+
8
+ const L = "\\p{L}*";
9
+
10
+ export const plRuleset: LanguageRuleset = {
11
+ language: "pl",
12
+ patterns: [
13
+ {
14
+ id: "announcement",
15
+ severity: "error",
16
+ pattern: wordPattern("(?:warto|należy) (?:zauważyć|podkreślić|wspomnieć|nadmienić|dodać|zaznaczyć)"),
17
+ message: "an announcement instead of a fact; write the fact",
18
+ },
19
+ {
20
+ id: "these-days",
21
+ severity: "error",
22
+ pattern: wordPattern(`w dzisiejszych czasach|w dzisiejszym (?:świecie|dynamicznym${L})|w dobie|w obecnych czasach`),
23
+ message: "an empty opener about \"these times\"; start with a number or the answer",
24
+ },
25
+ {
26
+ id: "not-only-but-also",
27
+ severity: "error",
28
+ pattern: wordPattern("nie tylko[^.!?]{1,120}?,? (?:ale|lecz) (?:także|również|też|i)"),
29
+ message: "\"not only…, but also\"; name both things plainly",
30
+ },
31
+ {
32
+ id: "not-x-but-y",
33
+ severity: "error",
34
+ pattern: wordPattern("(?:to nie (?:jest )?[^.!?,]{1,60}, (?:to|tylko|lecz)|nie chodzi o [^.!?]{1,80}[.,] (?:chodzi o|tylko o|lecz o))"),
35
+ message: "the \"it is not X, it is Y\" contrast; say Y right away",
36
+ },
37
+ {
38
+ id: "meta-commentary",
39
+ severity: "error",
40
+ pattern: wordPattern(
41
+ "w (?:tym|niniejszym) (?:artykule|tekście|wpisie|poradniku)|przyjrzyjmy się|zanurzmy się|przejdźmy (?:teraz )?do|jak (?:już )?wspomniano|jak zaraz zobaczysz|jak zobaczymy|dowiesz się (?:z tego|w tym)",
42
+ ),
43
+ message: "the text talks about itself instead of the topic; remove the announcement",
44
+ },
45
+ {
46
+ id: "throat-clearing",
47
+ severity: "error",
48
+ pattern: wordPattern(
49
+ "prawda jest taka|oto (?:dlaczego|jak|co|czemu)|i tu pojawia się|co ciekawe|co ważne|co istotne|nie da się ukryć|nie ulega wątpliwości|bez wątpienia|niewątpliwie|bezsprzecznie",
50
+ ),
51
+ message: "a run-up before the point; cut it and start with the point",
52
+ },
53
+ {
54
+ id: "empty-conclusion",
55
+ severity: "error",
56
+ pattern: wordPattern("podsumowując|reasumując|w podsumowaniu|na zakończenie|konkludując|krótko podsumowując"),
57
+ message: "an empty conclusion; end with the last concrete fact",
58
+ },
59
+ {
60
+ id: "crucial",
61
+ severity: "error",
62
+ pattern: wordPattern(`kluczow${L}`),
63
+ message: "\"crucial\", the most common Polish tic of language models; name what depends on the thing",
64
+ },
65
+ {
66
+ id: "plays-a-role",
67
+ severity: "error",
68
+ pattern: wordPattern(`odgryw${L} (?:\\p{L}+ )?rolę`),
69
+ message: "\"plays a role\"; write what the thing does",
70
+ },
71
+ {
72
+ id: "puffery",
73
+ severity: "error",
74
+ pattern: wordPattern(
75
+ `kamień milowy|przełomow${L}|rewolucyjn${L}|game[- ]changer${L}|klucz do sukcesu|nieocenion${L}|holistyczn${L}|kompleksow${L}|szerok${L} (?:wachlarz${L}|gam${L}|spektrum)|w gąszczu|dynamicznie zmieniając${L}|nie ma jednej (?:dobrej |właściwej |uniwersalnej |prostej )?odpowiedzi|zależy od wielu czynników`,
76
+ ),
77
+ message: "an inflated or empty phrase; replace it with a fact",
78
+ },
79
+ {
80
+ id: "chatbot-phrases",
81
+ severity: "error",
82
+ pattern: wordPattern("mam nadzieję, że|daj (?:mi )?znać|chętnie pomogę|świetne pytanie|jako model językowy|według mojej wiedzy|na dzień mojej wiedzy|w razie pytań"),
83
+ message: "a leftover of a chat with an assistant; remove it",
84
+ },
85
+ {
86
+ id: "emoji",
87
+ severity: "error",
88
+ pattern: /\p{Extended_Pictographic}/gu,
89
+ message: "an emoji in the text; remove it",
90
+ },
91
+ {
92
+ id: "filler-words",
93
+ severity: "warning",
94
+ pattern: wordPattern(
95
+ `ponadto|dodatkowo|co więcej|istotn${L}|fundamentaln${L}|innymi słowy|krótko mówiąc|w rezultacie|niezwykle|niezmiernie|naprawdę|absolutnie|zdecydowanie|dosłownie|po prostu|optymaln${L}|efektywn${L}|pamiętaj, że|warto`,
96
+ ),
97
+ message: "a filler word; check whether the sentence says the same without it",
98
+ },
99
+ {
100
+ id: "exclamation",
101
+ severity: "warning",
102
+ pattern: /!/g,
103
+ message: "an exclamation mark; a guide speaks calmly",
104
+ },
105
+ {
106
+ id: "straight-quotes",
107
+ severity: "warning",
108
+ pattern: /[“"]/g,
109
+ message: "an English quotation mark; Polish uses „…”",
110
+ },
111
+ ],
112
+ firstPersonSingular: wordPattern("ja|mnie|mój|moja|moje|mojego|mojej|moim|moich|moją|mi|uważam|sądzę|myślę|moim zdaniem|osobiście"),
113
+ profitPromises: wordPattern(
114
+ `gwarantowan${L} (?:zysk${L}|zwrot${L}|dochód${L}|zarobek${L})|pewn${L} zysk${L}|(?<!nie (?:jest|są|ma|będzie) )bez (?:żadnego )?ryzyka|zero ryzyka|nie (?:możesz|można) (?:nic )?stracić|na pewno zarobisz|zarobisz na pewno|kup(?:uj)? (?:teraz|już dziś)|musisz (?:kupić|zainwestować)|szybk${L} (?:zysk${L}|wzbogacen${L})|łatw${L} pieniądz${L}|bez wysiłku`,
115
+ ),
116
+ notation: {
117
+ number: /\d{1,3}(?:[ \u00a0\u202f]\d{3})+(?:,\d+)?|\d+(?:,\d+)?/g,
118
+ unitAfter: /^\s?(?:%|proc|zł|złotych|zl\b|PLN|tys|mln|mld|groszy|gr\b)/iu,
119
+ unitBefore: null,
120
+ referenceBefore: /(?:art\.|ust\.|poz\.|pkt|§|nr|Dz\.\s?U\.?|M\.P\.)\s*$/iu,
121
+ thousandsSeparator: /[ \u00a0\u202f]/,
122
+ decimalSeparator: ",",
123
+ },
124
+ triad: /(?<![\p{L}])\p{L}+, \p{L}+,? (?:i|oraz) \p{L}+(?![\p{L}])/gu,
125
+ flagsTitleCaseHeadings: true,
126
+ };
@@ -0,0 +1,31 @@
1
+ // The shape of a language ruleset: the language data the style, voice and YMYL rules match against.
2
+ import type { QualitySeverity } from "../finding.js";
3
+ import type { NumberNotation } from "../text.js";
4
+
5
+ export const QUALITY_LANGUAGES = ["en", "pl"] as const;
6
+ export type QualityLanguage = (typeof QUALITY_LANGUAGES)[number];
7
+
8
+ export interface StylePattern {
9
+ /** The rule id; the same id in two rulesets names the same habit. */
10
+ readonly id: string;
11
+ readonly severity: QualitySeverity;
12
+ /** Global, so every hit counts. */
13
+ readonly pattern: RegExp;
14
+ /** What is wrong and what to write instead. */
15
+ readonly message: string;
16
+ }
17
+
18
+ export interface LanguageRuleset {
19
+ readonly language: QualityLanguage;
20
+ /** AI-writing and generic style patterns. Errors: one hit is enough. Warnings: reported once per rule with a count. */
21
+ readonly patterns: readonly StylePattern[];
22
+ /** First person singular, for apps whose texts are signed by an editorial team (`voice.forbidFirstPersonSingular`). */
23
+ readonly firstPersonSingular: RegExp;
24
+ /** Promises of profit and orders to buy (YMYL). */
25
+ readonly profitPromises: RegExp;
26
+ readonly notation: NumberNotation;
27
+ /** An enumeration of three ("a, b and c"); more than three in a text reads as a template. */
28
+ readonly triad: RegExp;
29
+ /** Whether Title Case Headings are a mistake in this language. */
30
+ readonly flagsTitleCaseHeadings: boolean;
31
+ }
@@ -0,0 +1,60 @@
1
+ // The gate's resolved settings: the parsed options plus what the app config adds (its origins and time
2
+ // zone) and the blog's image policy, with the ruleset and voice patterns compiled once.
3
+ import type { SoftureConfig } from "@softure-ai/core";
4
+ import type { ArticleImagePolicy } from "../render/images.js";
5
+ import type { QualitySeverity } from "./finding.js";
6
+ import type { QualityOptions } from "./options.js";
7
+ import { QUALITY_RULESETS } from "./rulesets/index.js";
8
+ import type { LanguageRuleset, StylePattern } from "./rulesets/types.js";
9
+
10
+ export interface QualitySettings {
11
+ readonly options: QualityOptions;
12
+ readonly ruleset: LanguageRuleset;
13
+ /** The app's voice phrases plus, when switched on, the ruleset's first person singular. */
14
+ readonly voicePatterns: readonly StylePattern[];
15
+ /** Origins whose absolute links are internal: the app's `appOrigin`, the site origin and `ownOrigins`. */
16
+ readonly ownOrigins: readonly string[];
17
+ /** IANA time zone of "today". */
18
+ readonly timeZone: string;
19
+ /** The app's image policy (`blog({ images })`); `null` when bodies may show no image. */
20
+ readonly images: ArticleImagePolicy | null;
21
+ }
22
+
23
+ export function resolveQualitySettings(
24
+ options: QualityOptions,
25
+ config: Pick<SoftureConfig, "appOrigin" | "timezone"> & {
26
+ /** The canonical site origin (core's `getSiteUrls(config).origin`) when it is not `appOrigin`. */
27
+ readonly siteOrigin?: string;
28
+ },
29
+ images: ArticleImagePolicy | null = null,
30
+ ): QualitySettings {
31
+ const ruleset = QUALITY_RULESETS[options.language];
32
+ const voicePatterns: StylePattern[] = options.voice.phrases.map((phrase) => ({
33
+ id: phrase.id,
34
+ severity: phrase.severity,
35
+ pattern: phrase.pattern,
36
+ message: phrase.message,
37
+ }));
38
+ if (options.voice.forbidFirstPersonSingular) {
39
+ voicePatterns.push({
40
+ id: "first-person-singular",
41
+ severity: "error",
42
+ pattern: ruleset.firstPersonSingular,
43
+ message: "first person singular; the texts are signed by the editors (\"we\", \"you\"), never \"I\"",
44
+ });
45
+ }
46
+ const origins = [config.appOrigin, ...(config.siteOrigin === undefined ? [] : [config.siteOrigin]), ...options.ownOrigins].map((origin) => new URL(origin).origin);
47
+ return { options, ruleset, voicePatterns, ownOrigins: [...new Set(origins)], timeZone: config.timezone, images };
48
+ }
49
+
50
+ /** `YYYY-MM-DD` of a moment in a time zone. */
51
+ export function getLocalDate(moment: Date, timeZone: string): string {
52
+ return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(moment);
53
+ }
54
+
55
+ /** The severity a finding of `rule` gets: the override, else its own; `null` for "off". */
56
+ export function getEffectiveSeverity(settings: QualitySettings, rule: string, severity: QualitySeverity): QualitySeverity | null {
57
+ const override = settings.options.severity[rule];
58
+ if (override === undefined) return severity;
59
+ return override === "off" ? null : override;
60
+ }
@@ -0,0 +1,113 @@
1
+ // Text helpers of the gate (FIRE_TRACKER `src/lib/blog/quality/text.ts`): prose without markup, words,
2
+ // sentences, links, footnote references and the numbers that need a source. Number notation comes
3
+ // from the language ruleset, so "28 260,50" and "28,260.50" both read as one number.
4
+
5
+ export interface MarkdownLink {
6
+ readonly text: string;
7
+ readonly url: string;
8
+ }
9
+
10
+ /** How a language writes numbers, and which numbers count as facts that need a source. */
11
+ export interface NumberNotation {
12
+ /** A number token; global. */
13
+ readonly number: RegExp;
14
+ /** A unit right after the number (`%`, a currency, "million"); tested on the text that follows. */
15
+ readonly unitAfter: RegExp;
16
+ /** A unit right before the number (`$`); tested on the text before. `null`: none. */
17
+ readonly unitBefore: RegExp | null;
18
+ /** A legal or list reference before the number ("art.", "§", "no."), which needs no source. */
19
+ readonly referenceBefore: RegExp;
20
+ /** The digits to remove before reading the number (thousands separators). */
21
+ readonly thousandsSeparator: RegExp;
22
+ readonly decimalSeparator: "," | ".";
23
+ }
24
+
25
+ // No `[` inside link text, link target or footnote id: each match attempt ends at the next `[`, so a
26
+ // long run of unclosed brackets stays linear instead of rescanning the rest of the text per bracket.
27
+ // An image (`![alt](src)`) is not a link: the lookbehind skips it, and `IMAGE` removes it from prose.
28
+ const LINK = /(?<!!)\[([^[\]]*)\]\(([^)\s[]+)(?:\s+"[^"]*")?\)/g;
29
+ const IMAGE = /!\[[^[\]]*\]\([^)\s[]+(?:\s+"[^"]*")?\)/g;
30
+ const FOOTNOTE_REF = /\[\^([^[\]]+)\]/g;
31
+ const URL_IN_TEXT = /https?:\/\/[^\s)>\]]+/g;
32
+
33
+ export function findLinks(text: string): MarkdownLink[] {
34
+ return [...text.matchAll(LINK)].map((match) => ({ text: match[1] ?? "", url: match[2] ?? "" }));
35
+ }
36
+
37
+ export function findFootnoteRefs(text: string): string[] {
38
+ return [...text.matchAll(FOOTNOTE_REF)].map((match) => match[1] ?? "");
39
+ }
40
+
41
+ /** Bare URLs in a text (a footnote definition), without trailing punctuation. */
42
+ export function findBareUrls(text: string): string[] {
43
+ return [...text.matchAll(URL_IN_TEXT)].map((match) => match[0].replace(/[.,;]$/, ""));
44
+ }
45
+
46
+ /** The prose a reader sees: no code, images, link targets, footnote refs, URLs or emphasis markers. */
47
+ export function toProse(text: string): string {
48
+ return text
49
+ .replace(/`[^`]*`/g, " ")
50
+ .replace(IMAGE, " ")
51
+ .replace(LINK, "$1")
52
+ .replace(FOOTNOTE_REF, "")
53
+ .replace(/<https?:\/\/[^>]+>/g, " ")
54
+ .replace(/https?:\/\/\S+/g, " ")
55
+ .replace(/\*\*|__/g, "")
56
+ .replace(/(?<![\p{L}\d])[*_](?=\S)|(?<=\S)[*_](?![\p{L}\d])/gu, "")
57
+ .replace(/\s+/g, " ")
58
+ .trim();
59
+ }
60
+
61
+ export function countWords(prose: string): number {
62
+ return prose.match(/[\p{L}\d][\p{L}\d'’-]*/gu)?.length ?? 0;
63
+ }
64
+
65
+ /**
66
+ * Sentences of prose. A boundary is `.`, `!`, `?` or `…` before an upper-case letter, a quote or a
67
+ * bracket, never before a digit, so "art. 27" and "approx. 300" do not cut a sentence.
68
+ */
69
+ export function splitSentences(prose: string): string[] {
70
+ return prose
71
+ .split(/(?<=[.!?…])\s+(?=[\p{Lu}„“"(])/u)
72
+ .map((sentence) => sentence.trim())
73
+ .filter((sentence) => sentence.length > 0);
74
+ }
75
+
76
+ /**
77
+ * The numbers that need a source: amounts, percentages and numbers from 1000 up. Ages, years
78
+ * (1900–2100), small counts and legal references ("art. 27") need none, or every text about law
79
+ * would fail.
80
+ */
81
+ export function findSignificantNumbers(prose: string, notation: NumberNotation): string[] {
82
+ const found: string[] = [];
83
+ for (const match of prose.matchAll(new RegExp(notation.number.source, "g"))) {
84
+ const raw = match[0];
85
+ const before = prose.slice(Math.max(0, match.index - 12), match.index);
86
+ const after = prose.slice(match.index + raw.length, match.index + raw.length + 12);
87
+ if (/[\p{L}\d]$/u.test(before) || notation.referenceBefore.test(before)) continue;
88
+ if (notation.unitAfter.test(after) || (notation.unitBefore?.test(before) ?? false)) {
89
+ found.push(raw);
90
+ continue;
91
+ }
92
+ const value = parseNumber(raw, notation);
93
+ const looksLikeYear = /^\d{4}$/.test(raw) && value >= 1900 && value <= 2100;
94
+ if (value >= 1000 && !looksLikeYear) found.push(raw);
95
+ }
96
+ return found;
97
+ }
98
+
99
+ /** "28 260,50" (pl) or "28,260.50" (en) → 28260.5. */
100
+ export function parseNumber(raw: string, notation: NumberNotation): number {
101
+ const digits = raw.replace(new RegExp(notation.thousandsSeparator.source, "g"), "");
102
+ return Number(notation.decimalSeparator === "," ? digits.replace(",", ".") : digits);
103
+ }
104
+
105
+ /** A number without thousands separators, to compare a text with a table. */
106
+ export function normalizeNumber(raw: string, notation: NumberNotation): string {
107
+ return raw.replace(new RegExp(notation.thousandsSeparator.source, "g"), "");
108
+ }
109
+
110
+ /** A case-insensitive pattern with word edges that know non-ASCII letters (`\b` knows only ASCII). */
111
+ export function wordPattern(source: string): RegExp {
112
+ return new RegExp(`(?<![\\p{L}\\d])(?:${source})(?![\\p{L}\\d])`, "giu");
113
+ }
@@ -0,0 +1,103 @@
1
+ // Matching glossary term forms in plain text: no Markdown and no database. The renderer
2
+ // (`render-article.ts`) calls the matcher on the text of paragraphs, lists and tables.
3
+ //
4
+ // - A form is a phrase from the term's file (`forms`), not a word cut out of an inflection: an editor
5
+ // picks "bridge to retirement", not "bridge", so a plain sentence about a bridge gets no link.
6
+ // - Word bounds are Unicode-aware. `\b` in JS knows only ASCII, so "IKE" would hit "IKEA" without
7
+ // lookarounds on `\p{L}`.
8
+ // - Case as written in the form, plus a variant with a capital first letter (start of a sentence).
9
+ // "IKE" does not hit "ike": an acronym in lower case is another word or a typo.
10
+ // - The longest form wins at the same position: "IKZE relief" before "IKZE" when both are terms.
11
+ import type { BlogArticle } from "../contract.js";
12
+
13
+ export interface GlossaryTerm {
14
+ readonly slug: string;
15
+ readonly forms: readonly string[];
16
+ }
17
+
18
+ export interface TermMatch {
19
+ readonly index: number;
20
+ readonly text: string;
21
+ readonly slug: string;
22
+ }
23
+
24
+ /** Every occurrence of a form in the text, left to right, without overlaps. */
25
+ export type TermMatcher = (text: string) => TermMatch[];
26
+
27
+ function escapeRegExp(text: string): string {
28
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
29
+ }
30
+
31
+ function capitalize(text: string): string {
32
+ return text.charAt(0).toUpperCase() + text.slice(1);
33
+ }
34
+
35
+ export function createTermMatcher(terms: readonly GlossaryTerm[]): TermMatcher {
36
+ const slugByForm = new Map<string, string>();
37
+ for (const term of terms) {
38
+ for (const form of term.forms) {
39
+ const trimmed = form.trim();
40
+ if (trimmed === "") continue;
41
+ // The first term with a given form wins: two terms with the same phrase are an editorial
42
+ // mistake (`findTermFormConflicts`: the publish run refuses it and `check` reports it), and the
43
+ // link has to be deterministic for rows stored before that check.
44
+ for (const variant of [trimmed, capitalize(trimmed)]) {
45
+ if (!slugByForm.has(variant)) slugByForm.set(variant, term.slug);
46
+ }
47
+ }
48
+ }
49
+ if (slugByForm.size === 0) return () => [];
50
+ const alternatives = [...slugByForm.keys()]
51
+ .sort((a, b) => b.length - a.length || a.localeCompare(b))
52
+ .map(escapeRegExp)
53
+ .join("|");
54
+ const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${alternatives})(?![\\p{L}\\p{N}])`, "gu");
55
+
56
+ return (text) =>
57
+ [...text.matchAll(pattern)].map((match) => ({
58
+ index: match.index,
59
+ text: match[0],
60
+ // Every alternative of the pattern is a key of the map.
61
+ slug: slugByForm.get(match[0]) ?? "",
62
+ }));
63
+ }
64
+
65
+ /** A form that two or more terms claim; `slugs` sorted, the form as the first of them writes it. */
66
+ export interface TermFormConflict {
67
+ readonly form: string;
68
+ readonly slugs: readonly string[];
69
+ }
70
+
71
+ /**
72
+ * Forms claimed by more than one term. Two forms collide for the matcher exactly when they are equal
73
+ * after a capital first letter ("ike" and "Ike" do; "IKE" and "Ike" do not), so that is the key. A term
74
+ * that lists one form twice is no conflict. Sorted by form, for stable messages.
75
+ */
76
+ export function findTermFormConflicts(terms: readonly GlossaryTerm[]): TermFormConflict[] {
77
+ const claims = new Map<string, Map<string, string>>();
78
+ for (const term of terms) {
79
+ for (const form of term.forms) {
80
+ const trimmed = form.trim();
81
+ if (trimmed === "") continue;
82
+ const key = capitalize(trimmed);
83
+ const bySlug = claims.get(key) ?? new Map<string, string>();
84
+ if (!bySlug.has(term.slug)) bySlug.set(term.slug, trimmed);
85
+ claims.set(key, bySlug);
86
+ }
87
+ }
88
+ return [...claims.values()]
89
+ .filter((bySlug) => bySlug.size > 1)
90
+ .map((bySlug) => {
91
+ const slugs = [...bySlug.keys()].sort();
92
+ // `slugs` holds the keys of the map, so the first one is there.
93
+ return { form: bySlug.get(slugs[0] ?? "") ?? "", slugs };
94
+ })
95
+ .sort((a, b) => a.form.localeCompare(b.form));
96
+ }
97
+
98
+ /** The glossary of a site from its stored rows: every `kind = "term"` row with its forms. */
99
+ export function toGlossary(articles: readonly Pick<BlogArticle, "kind" | "slug" | "termForms">[]): GlossaryTerm[] {
100
+ return articles
101
+ .filter((article) => article.kind === "term" && article.termForms.length > 0)
102
+ .map((article) => ({ slug: article.slug, forms: article.termForms }));
103
+ }
@@ -0,0 +1,110 @@
1
+ // Images in article bodies under the app's image policy: one check for the renderer (emit or not) and
2
+ // the quality gate (report why not). An image follows the policy when its source stays on the site (a
3
+ // root-relative path) or comes over https from a host the app allows, its alt text is not empty and the
4
+ // app knows its width and height, so the page reserves its box before it loads.
5
+ import MarkdownIt from "markdown-it";
6
+ import footnote from "markdown-it-footnote";
7
+
8
+ export interface ImageDimensions {
9
+ readonly width: number;
10
+ readonly height: number;
11
+ }
12
+
13
+ export interface ArticleImagePolicy {
14
+ /** Hosts whose https images are allowed besides the site's own paths (`cdn.example.com` covers its subdomains). */
15
+ readonly hosts?: readonly string[];
16
+ /**
17
+ * Width and height in pixels of an allowed source, or `null` when the app does not know them. The
18
+ * source is the normalised URL the page requests (percent-encoded: a space is `%20`).
19
+ */
20
+ readonly dimensions: (src: string) => ImageDimensions | null;
21
+ }
22
+
23
+ export type ImageProblem = "source" | "alt" | "dimensions";
24
+
25
+ export type ImageVerdict =
26
+ | { readonly ok: true; readonly width: number; readonly height: number }
27
+ | { readonly ok: false; readonly problems: readonly ImageProblem[] };
28
+
29
+ export interface ArticleImage {
30
+ readonly src: string;
31
+ readonly alt: string;
32
+ }
33
+
34
+ export interface FoundImage extends ArticleImage {
35
+ /** 1-based line of the block that holds the image. */
36
+ readonly line: number;
37
+ }
38
+
39
+ // A placeholder origin: a root-relative source must resolve to it, or it leaves the site.
40
+ const SITE_BASE = "https://site.invalid";
41
+
42
+ function isOnSite(src: string): boolean {
43
+ if (!src.startsWith("/")) return false;
44
+ try {
45
+ return new URL(src, SITE_BASE).origin === SITE_BASE;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function isOnAllowedHost(src: string, hosts: readonly string[]): boolean {
52
+ let url: URL;
53
+ try {
54
+ url = new URL(src);
55
+ } catch {
56
+ return false;
57
+ }
58
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "") return false;
59
+ const host = url.hostname.toLowerCase();
60
+ return hosts.some((allowed) => {
61
+ const own = allowed.toLowerCase();
62
+ return host === own || host.endsWith(`.${own}`);
63
+ });
64
+ }
65
+
66
+ function isAllowedSource(src: string, policy: ArticleImagePolicy | undefined): boolean {
67
+ if (policy === undefined) return false;
68
+ return isOnSite(src) || isOnAllowedHost(src, policy.hosts ?? []);
69
+ }
70
+
71
+ function isPixelCount(value: unknown): value is number {
72
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
73
+ }
74
+
75
+ /**
76
+ * Whether an image follows the policy, and why not. Without a policy no image does. `dimensions` is
77
+ * asked only for an allowed source; a throw there is the app's bug and propagates.
78
+ */
79
+ export function checkArticleImage(image: ArticleImage, policy: ArticleImagePolicy | undefined): ImageVerdict {
80
+ const problems: ImageProblem[] = [];
81
+ const isAllowed = isAllowedSource(image.src, policy);
82
+ if (!isAllowed) problems.push("source");
83
+ if (image.alt.trim() === "") problems.push("alt");
84
+ const size = isAllowed && policy !== undefined ? policy.dimensions(image.src) : null;
85
+ const hasSize = size !== null && isPixelCount(size.width) && isPixelCount(size.height);
86
+ if (isAllowed && !hasSize) problems.push("dimensions");
87
+ if (problems.length > 0 || size === null) return { ok: false, problems };
88
+ return { ok: true, width: size.width, height: size.height };
89
+ }
90
+
91
+ /**
92
+ * The images of a text with their lines, for the quality gate. It parses like the renderer, but lets
93
+ * every link scheme through, so an image the renderer would drop as text (`javascript:`) is listed and
94
+ * reported too.
95
+ */
96
+ export function findArticleImages(markdown: string): FoundImage[] {
97
+ const md = new MarkdownIt({ html: false, linkify: false, typographer: false });
98
+ md.use(footnote);
99
+ md.validateLink = () => true;
100
+ const images: FoundImage[] = [];
101
+ for (const token of md.parse(markdown, {})) {
102
+ if (token.type !== "inline" || token.children === null) continue;
103
+ const line = (token.map?.[0] ?? 0) + 1;
104
+ for (const child of token.children) {
105
+ if (child.type !== "image") continue;
106
+ images.push({ src: String(child.attrGet("src") ?? ""), alt: md.renderer.renderInlineAsText(child.children ?? [], md.options, {}), line });
107
+ }
108
+ }
109
+ return images;
110
+ }
@@ -0,0 +1,29 @@
1
+ // Server-side article rendering: Markdown to safe HTML, images under a policy, glossary links, block
2
+ // plugins, reading time.
3
+ export { createTermMatcher, findTermFormConflicts, toGlossary, type GlossaryTerm, type TermFormConflict, type TermMatch, type TermMatcher } from "./glossary.js";
4
+ export {
5
+ checkArticleImage,
6
+ findArticleImages,
7
+ type ArticleImage,
8
+ type ArticleImagePolicy,
9
+ type FoundImage,
10
+ type ImageDimensions,
11
+ type ImageProblem,
12
+ type ImageVerdict,
13
+ } from "./images.js";
14
+ export { DEFAULT_WORDS_PER_MINUTE, getReadingMinutes } from "./reading-time.js";
15
+ export {
16
+ findArticleBlocks,
17
+ renderArticle,
18
+ type ArticleBlock,
19
+ type ArticleHeading,
20
+ type ArticleSegment,
21
+ type BlockArticle,
22
+ type BlockOutput,
23
+ type BlockPlugin,
24
+ type BlogRenderMessages,
25
+ type FoundBlock,
26
+ type RenderArticleOptions,
27
+ type RenderedArticle,
28
+ } from "./render-article.js";
29
+ export { slugifyHeading } from "./slugify-heading.js";
@@ -0,0 +1,18 @@
1
+ // Reading time of an article body.
2
+
3
+ /** Words per minute assumed for prose of an article. */
4
+ export const DEFAULT_WORDS_PER_MINUTE = 200;
5
+
6
+ /**
7
+ * Minutes needed to read the text: words of prose (footnote definitions, URLs and Markdown syntax
8
+ * removed), rounded up, at least 1.
9
+ */
10
+ export function getReadingMinutes(markdown: string, wordsPerMinute: number = DEFAULT_WORDS_PER_MINUTE): number {
11
+ const prose = markdown
12
+ .replace(/^\[\^[^\]]+\]:.*$/gm, "")
13
+ .replace(/https?:\/\/\S+/g, "")
14
+ .replace(/\[\^[^\]]+\]/g, "")
15
+ .replace(/[#>*_|`[\]()-]/g, " ");
16
+ const words = prose.split(/\s+/).filter((word) => /\p{L}|\d/u.test(word)).length;
17
+ return Math.max(1, Math.ceil(words / wordsPerMinute));
18
+ }