@dxos/plugin-magazine 0.0.0

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 (390) hide show
  1. package/LICENSE +105 -0
  2. package/PLUGIN.mdl +653 -0
  3. package/dist/lib/neutral/FeedArticle-PEWCFFSP.mjs +90 -0
  4. package/dist/lib/neutral/FeedArticle-PEWCFFSP.mjs.map +7 -0
  5. package/dist/lib/neutral/FeedProperties-EIO7IZOA.mjs +97 -0
  6. package/dist/lib/neutral/FeedProperties-EIO7IZOA.mjs.map +7 -0
  7. package/dist/lib/neutral/MagazineArticle-DEUTR23F.mjs +325 -0
  8. package/dist/lib/neutral/MagazineArticle-DEUTR23F.mjs.map +7 -0
  9. package/dist/lib/neutral/MagazinePlugin.mjs +62 -0
  10. package/dist/lib/neutral/MagazinePlugin.mjs.map +7 -0
  11. package/dist/lib/neutral/MagazinePlugin.workerd.mjs +12 -0
  12. package/dist/lib/neutral/MagazinePlugin.workerd.mjs.map +7 -0
  13. package/dist/lib/neutral/PostArticle-RERHXPXC.mjs +230 -0
  14. package/dist/lib/neutral/PostArticle-RERHXPXC.mjs.map +7 -0
  15. package/dist/lib/neutral/PostCard-VDGN7BBA.mjs +60 -0
  16. package/dist/lib/neutral/PostCard-VDGN7BBA.mjs.map +7 -0
  17. package/dist/lib/neutral/app-graph-builder-JDPRBVHL.mjs +121 -0
  18. package/dist/lib/neutral/app-graph-builder-JDPRBVHL.mjs.map +7 -0
  19. package/dist/lib/neutral/capabilities/index.mjs +21 -0
  20. package/dist/lib/neutral/capabilities/index.mjs.map +7 -0
  21. package/dist/lib/neutral/chunk-3K4AQKBZ.mjs +12 -0
  22. package/dist/lib/neutral/chunk-3K4AQKBZ.mjs.map +7 -0
  23. package/dist/lib/neutral/chunk-AFPBWBTK.mjs +591 -0
  24. package/dist/lib/neutral/chunk-AFPBWBTK.mjs.map +7 -0
  25. package/dist/lib/neutral/chunk-APQRTYT3.mjs +57 -0
  26. package/dist/lib/neutral/chunk-APQRTYT3.mjs.map +7 -0
  27. package/dist/lib/neutral/chunk-GU725ZF4.mjs +11 -0
  28. package/dist/lib/neutral/chunk-GU725ZF4.mjs.map +7 -0
  29. package/dist/lib/neutral/chunk-J5LGTIGS.mjs +10 -0
  30. package/dist/lib/neutral/chunk-J5LGTIGS.mjs.map +7 -0
  31. package/dist/lib/neutral/chunk-JFPR6OV4.mjs +60 -0
  32. package/dist/lib/neutral/chunk-JFPR6OV4.mjs.map +7 -0
  33. package/dist/lib/neutral/chunk-KTYLUOUG.mjs +36 -0
  34. package/dist/lib/neutral/chunk-KTYLUOUG.mjs.map +7 -0
  35. package/dist/lib/neutral/chunk-MBPX7RPI.mjs +14 -0
  36. package/dist/lib/neutral/chunk-MBPX7RPI.mjs.map +7 -0
  37. package/dist/lib/neutral/chunk-MJ2AY2CK.mjs +8 -0
  38. package/dist/lib/neutral/chunk-MJ2AY2CK.mjs.map +7 -0
  39. package/dist/lib/neutral/chunk-OYCDRSWB.mjs +624 -0
  40. package/dist/lib/neutral/chunk-OYCDRSWB.mjs.map +7 -0
  41. package/dist/lib/neutral/chunk-SAWKLGJU.mjs +28 -0
  42. package/dist/lib/neutral/chunk-SAWKLGJU.mjs.map +7 -0
  43. package/dist/lib/neutral/chunk-XCNCK47N.mjs +15 -0
  44. package/dist/lib/neutral/chunk-XCNCK47N.mjs.map +7 -0
  45. package/dist/lib/neutral/clear-magazine-KNY4OWY4.mjs +59 -0
  46. package/dist/lib/neutral/clear-magazine-KNY4OWY4.mjs.map +7 -0
  47. package/dist/lib/neutral/components/index.mjs +338 -0
  48. package/dist/lib/neutral/components/index.mjs.map +7 -0
  49. package/dist/lib/neutral/containers/index.mjs +17 -0
  50. package/dist/lib/neutral/containers/index.mjs.map +7 -0
  51. package/dist/lib/neutral/create-object-SYZMRR4H.mjs +98 -0
  52. package/dist/lib/neutral/create-object-SYZMRR4H.mjs.map +7 -0
  53. package/dist/lib/neutral/curate-magazine-MA6OLK2X.mjs +184 -0
  54. package/dist/lib/neutral/curate-magazine-MA6OLK2X.mjs.map +7 -0
  55. package/dist/lib/neutral/fetch-article-content-CZO6D3QD.mjs +31 -0
  56. package/dist/lib/neutral/fetch-article-content-CZO6D3QD.mjs.map +7 -0
  57. package/dist/lib/neutral/index.mjs +18 -0
  58. package/dist/lib/neutral/index.mjs.map +7 -0
  59. package/dist/lib/neutral/load-post-content-XGL6VFF6.mjs +65 -0
  60. package/dist/lib/neutral/load-post-content-XGL6VFF6.mjs.map +7 -0
  61. package/dist/lib/neutral/meta.json +1 -0
  62. package/dist/lib/neutral/meta.mjs +8 -0
  63. package/dist/lib/neutral/meta.mjs.map +7 -0
  64. package/dist/lib/neutral/navigation-resolver-IN4OMCDR.mjs +18 -0
  65. package/dist/lib/neutral/navigation-resolver-IN4OMCDR.mjs.map +7 -0
  66. package/dist/lib/neutral/operation-handler-JE3743BU.mjs +8 -0
  67. package/dist/lib/neutral/operation-handler-JE3743BU.mjs.map +7 -0
  68. package/dist/lib/neutral/operations/index.mjs +8 -0
  69. package/dist/lib/neutral/operations/index.mjs.map +7 -0
  70. package/dist/lib/neutral/plugin.mjs +16 -0
  71. package/dist/lib/neutral/plugin.mjs.map +7 -0
  72. package/dist/lib/neutral/plugin.workerd.mjs +16 -0
  73. package/dist/lib/neutral/plugin.workerd.mjs.map +7 -0
  74. package/dist/lib/neutral/react-surface-TZESAMG2.mjs +60 -0
  75. package/dist/lib/neutral/react-surface-TZESAMG2.mjs.map +7 -0
  76. package/dist/lib/neutral/routine-templates-A2XTJOZO.mjs +52 -0
  77. package/dist/lib/neutral/routine-templates-A2XTJOZO.mjs.map +7 -0
  78. package/dist/lib/neutral/skill-definition-3KLMUEC5.mjs +8 -0
  79. package/dist/lib/neutral/skill-definition-3KLMUEC5.mjs.map +7 -0
  80. package/dist/lib/neutral/skills/index.mjs +46 -0
  81. package/dist/lib/neutral/skills/index.mjs.map +7 -0
  82. package/dist/lib/neutral/sync-feed-UZX2XHSQ.mjs +99 -0
  83. package/dist/lib/neutral/sync-feed-UZX2XHSQ.mjs.map +7 -0
  84. package/dist/lib/neutral/translations.mjs +81 -0
  85. package/dist/lib/neutral/translations.mjs.map +7 -0
  86. package/dist/lib/neutral/types/index.mjs +14 -0
  87. package/dist/lib/neutral/types/index.mjs.map +7 -0
  88. package/dist/types/dx.config.d.ts +28 -0
  89. package/dist/types/dx.config.d.ts.map +1 -0
  90. package/dist/types/src/MagazinePlugin.d.ts +4 -0
  91. package/dist/types/src/MagazinePlugin.d.ts.map +1 -0
  92. package/dist/types/src/MagazinePlugin.test.d.ts +2 -0
  93. package/dist/types/src/MagazinePlugin.test.d.ts.map +1 -0
  94. package/dist/types/src/MagazinePlugin.workerd.d.ts +4 -0
  95. package/dist/types/src/MagazinePlugin.workerd.d.ts.map +1 -0
  96. package/dist/types/src/atoms/atoms.test.d.ts +2 -0
  97. package/dist/types/src/atoms/atoms.test.d.ts.map +1 -0
  98. package/dist/types/src/atoms/index.d.ts +7 -0
  99. package/dist/types/src/atoms/index.d.ts.map +1 -0
  100. package/dist/types/src/atoms/magazine-posts.d.ts +16 -0
  101. package/dist/types/src/atoms/magazine-posts.d.ts.map +1 -0
  102. package/dist/types/src/atoms/post-content.d.ts +7 -0
  103. package/dist/types/src/atoms/post-content.d.ts.map +1 -0
  104. package/dist/types/src/atoms/post-curation.d.ts +16 -0
  105. package/dist/types/src/atoms/post-curation.d.ts.map +1 -0
  106. package/dist/types/src/atoms/post-display.d.ts +22 -0
  107. package/dist/types/src/atoms/post-display.d.ts.map +1 -0
  108. package/dist/types/src/atoms/post-read.d.ts +14 -0
  109. package/dist/types/src/atoms/post-read.d.ts.map +1 -0
  110. package/dist/types/src/atoms/post-tags.d.ts +16 -0
  111. package/dist/types/src/atoms/post-tags.d.ts.map +1 -0
  112. package/dist/types/src/capabilities/app-graph-builder.d.ts +5 -0
  113. package/dist/types/src/capabilities/app-graph-builder.d.ts.map +1 -0
  114. package/dist/types/src/capabilities/create-object.d.ts +5 -0
  115. package/dist/types/src/capabilities/create-object.d.ts.map +1 -0
  116. package/dist/types/src/capabilities/index.d.ts +10 -0
  117. package/dist/types/src/capabilities/index.d.ts.map +1 -0
  118. package/dist/types/src/capabilities/navigation-resolver.d.ts +5 -0
  119. package/dist/types/src/capabilities/navigation-resolver.d.ts.map +1 -0
  120. package/dist/types/src/capabilities/operation-handler.d.ts +6 -0
  121. package/dist/types/src/capabilities/operation-handler.d.ts.map +1 -0
  122. package/dist/types/src/capabilities/react-surface.d.ts +5 -0
  123. package/dist/types/src/capabilities/react-surface.d.ts.map +1 -0
  124. package/dist/types/src/capabilities/routine-templates.d.ts +5 -0
  125. package/dist/types/src/capabilities/routine-templates.d.ts.map +1 -0
  126. package/dist/types/src/capabilities/skill-definition.d.ts +5 -0
  127. package/dist/types/src/capabilities/skill-definition.d.ts.map +1 -0
  128. package/dist/types/src/components/PostContent/PostContent.d.ts +30 -0
  129. package/dist/types/src/components/PostContent/PostContent.d.ts.map +1 -0
  130. package/dist/types/src/components/PostContent/PostContent.test.d.ts +2 -0
  131. package/dist/types/src/components/PostContent/PostContent.test.d.ts.map +1 -0
  132. package/dist/types/src/components/PostContent/index.d.ts +2 -0
  133. package/dist/types/src/components/PostContent/index.d.ts.map +1 -0
  134. package/dist/types/src/components/PostStack/PostStack.d.ts +17 -0
  135. package/dist/types/src/components/PostStack/PostStack.d.ts.map +1 -0
  136. package/dist/types/src/components/PostStack/PostStack.stories.d.ts +10 -0
  137. package/dist/types/src/components/PostStack/PostStack.stories.d.ts.map +1 -0
  138. package/dist/types/src/components/PostStack/index.d.ts +2 -0
  139. package/dist/types/src/components/PostStack/index.d.ts.map +1 -0
  140. package/dist/types/src/components/SubscriptionStack/SubscriptionStack.d.ts +23 -0
  141. package/dist/types/src/components/SubscriptionStack/SubscriptionStack.d.ts.map +1 -0
  142. package/dist/types/src/components/SubscriptionStack/SubscriptionStack.stories.d.ts +8 -0
  143. package/dist/types/src/components/SubscriptionStack/SubscriptionStack.stories.d.ts.map +1 -0
  144. package/dist/types/src/components/SubscriptionStack/index.d.ts +2 -0
  145. package/dist/types/src/components/SubscriptionStack/index.d.ts.map +1 -0
  146. package/dist/types/src/components/index.d.ts +4 -0
  147. package/dist/types/src/components/index.d.ts.map +1 -0
  148. package/dist/types/src/containers/FeedArticle/FeedArticle.d.ts +6 -0
  149. package/dist/types/src/containers/FeedArticle/FeedArticle.d.ts.map +1 -0
  150. package/dist/types/src/containers/FeedArticle/FeedArticle.stories.d.ts +12 -0
  151. package/dist/types/src/containers/FeedArticle/FeedArticle.stories.d.ts.map +1 -0
  152. package/dist/types/src/containers/FeedArticle/FeedToolbar.d.ts +10 -0
  153. package/dist/types/src/containers/FeedArticle/FeedToolbar.d.ts.map +1 -0
  154. package/dist/types/src/containers/FeedArticle/index.d.ts +2 -0
  155. package/dist/types/src/containers/FeedArticle/index.d.ts.map +1 -0
  156. package/dist/types/src/containers/FeedProperties/FeedProperties.d.ts +6 -0
  157. package/dist/types/src/containers/FeedProperties/FeedProperties.d.ts.map +1 -0
  158. package/dist/types/src/containers/FeedProperties/index.d.ts +2 -0
  159. package/dist/types/src/containers/FeedProperties/index.d.ts.map +1 -0
  160. package/dist/types/src/containers/MagazineArticle/MagazineArticle.d.ts +6 -0
  161. package/dist/types/src/containers/MagazineArticle/MagazineArticle.d.ts.map +1 -0
  162. package/dist/types/src/containers/MagazineArticle/MagazineArticle.stories.d.ts +9 -0
  163. package/dist/types/src/containers/MagazineArticle/MagazineArticle.stories.d.ts.map +1 -0
  164. package/dist/types/src/containers/MagazineArticle/MagazineTile.d.ts +11 -0
  165. package/dist/types/src/containers/MagazineArticle/MagazineTile.d.ts.map +1 -0
  166. package/dist/types/src/containers/MagazineArticle/index.d.ts +2 -0
  167. package/dist/types/src/containers/MagazineArticle/index.d.ts.map +1 -0
  168. package/dist/types/src/containers/MagazineArticle/useToolbar.d.ts +18 -0
  169. package/dist/types/src/containers/MagazineArticle/useToolbar.d.ts.map +1 -0
  170. package/dist/types/src/containers/PostArticle/PostArticle.d.ts +6 -0
  171. package/dist/types/src/containers/PostArticle/PostArticle.d.ts.map +1 -0
  172. package/dist/types/src/containers/PostArticle/PostArticle.stories.d.ts +8 -0
  173. package/dist/types/src/containers/PostArticle/PostArticle.stories.d.ts.map +1 -0
  174. package/dist/types/src/containers/PostArticle/PostToolbar.d.ts +19 -0
  175. package/dist/types/src/containers/PostArticle/PostToolbar.d.ts.map +1 -0
  176. package/dist/types/src/containers/PostArticle/index.d.ts +2 -0
  177. package/dist/types/src/containers/PostArticle/index.d.ts.map +1 -0
  178. package/dist/types/src/containers/PostCard/PostCard.d.ts +11 -0
  179. package/dist/types/src/containers/PostCard/PostCard.d.ts.map +1 -0
  180. package/dist/types/src/containers/PostCard/index.d.ts +2 -0
  181. package/dist/types/src/containers/PostCard/index.d.ts.map +1 -0
  182. package/dist/types/src/containers/SubscriptionsArticle/SubscriptionsArticle.d.ts +5 -0
  183. package/dist/types/src/containers/SubscriptionsArticle/SubscriptionsArticle.d.ts.map +1 -0
  184. package/dist/types/src/containers/SubscriptionsArticle/SubscriptionsArticle.stories.d.ts +8 -0
  185. package/dist/types/src/containers/SubscriptionsArticle/SubscriptionsArticle.stories.d.ts.map +1 -0
  186. package/dist/types/src/containers/SubscriptionsArticle/index.d.ts +2 -0
  187. package/dist/types/src/containers/SubscriptionsArticle/index.d.ts.map +1 -0
  188. package/dist/types/src/containers/index.d.ts +7 -0
  189. package/dist/types/src/containers/index.d.ts.map +1 -0
  190. package/dist/types/src/index.d.ts +3 -0
  191. package/dist/types/src/index.d.ts.map +1 -0
  192. package/dist/types/src/meta.d.ts +33 -0
  193. package/dist/types/src/meta.d.ts.map +1 -0
  194. package/dist/types/src/operations/clear-magazine.d.ts +10 -0
  195. package/dist/types/src/operations/clear-magazine.d.ts.map +1 -0
  196. package/dist/types/src/operations/curate-magazine.d.ts +25 -0
  197. package/dist/types/src/operations/curate-magazine.d.ts.map +1 -0
  198. package/dist/types/src/operations/curate-magazine.skill.test.d.ts +2 -0
  199. package/dist/types/src/operations/curate-magazine.skill.test.d.ts.map +1 -0
  200. package/dist/types/src/operations/curate-magazine.test.d.ts +2 -0
  201. package/dist/types/src/operations/curate-magazine.test.d.ts.map +1 -0
  202. package/dist/types/src/operations/extraction/article.d.ts +37 -0
  203. package/dist/types/src/operations/extraction/article.d.ts.map +1 -0
  204. package/dist/types/src/operations/extraction/article.test.d.ts +2 -0
  205. package/dist/types/src/operations/extraction/article.test.d.ts.map +1 -0
  206. package/dist/types/src/operations/extraction/index.d.ts +2 -0
  207. package/dist/types/src/operations/extraction/index.d.ts.map +1 -0
  208. package/dist/types/src/operations/fetch-article-content.d.ts +5 -0
  209. package/dist/types/src/operations/fetch-article-content.d.ts.map +1 -0
  210. package/dist/types/src/operations/index.d.ts +3 -0
  211. package/dist/types/src/operations/index.d.ts.map +1 -0
  212. package/dist/types/src/operations/load-post-content.d.ts +8 -0
  213. package/dist/types/src/operations/load-post-content.d.ts.map +1 -0
  214. package/dist/types/src/operations/sources/article.d.ts +24 -0
  215. package/dist/types/src/operations/sources/article.d.ts.map +1 -0
  216. package/dist/types/src/operations/sources/cors.d.ts +12 -0
  217. package/dist/types/src/operations/sources/cors.d.ts.map +1 -0
  218. package/dist/types/src/operations/sources/feed-fetcher.d.ts +22 -0
  219. package/dist/types/src/operations/sources/feed-fetcher.d.ts.map +1 -0
  220. package/dist/types/src/operations/sources/feed-fetcher.test.d.ts +2 -0
  221. package/dist/types/src/operations/sources/feed-fetcher.test.d.ts.map +1 -0
  222. package/dist/types/src/operations/sources/http.d.ts +9 -0
  223. package/dist/types/src/operations/sources/http.d.ts.map +1 -0
  224. package/dist/types/src/operations/sources/index.d.ts +6 -0
  225. package/dist/types/src/operations/sources/index.d.ts.map +1 -0
  226. package/dist/types/src/operations/sources/rss.d.ts +4 -0
  227. package/dist/types/src/operations/sources/rss.d.ts.map +1 -0
  228. package/dist/types/src/operations/sources/rss.test.d.ts +2 -0
  229. package/dist/types/src/operations/sources/rss.test.d.ts.map +1 -0
  230. package/dist/types/src/operations/sources/standard-site.d.ts +33 -0
  231. package/dist/types/src/operations/sources/standard-site.d.ts.map +1 -0
  232. package/dist/types/src/operations/sync-feed.d.ts +5 -0
  233. package/dist/types/src/operations/sync-feed.d.ts.map +1 -0
  234. package/dist/types/src/operations/sync-feed.test.d.ts +2 -0
  235. package/dist/types/src/operations/sync-feed.test.d.ts.map +1 -0
  236. package/dist/types/src/operations/util.d.ts +25 -0
  237. package/dist/types/src/operations/util.d.ts.map +1 -0
  238. package/dist/types/src/operations/util.test.d.ts +2 -0
  239. package/dist/types/src/operations/util.test.d.ts.map +1 -0
  240. package/dist/types/src/paths.d.ts +3 -0
  241. package/dist/types/src/paths.d.ts.map +1 -0
  242. package/dist/types/src/plugin.d.ts +4 -0
  243. package/dist/types/src/plugin.d.ts.map +1 -0
  244. package/dist/types/src/plugin.workerd.d.ts +3 -0
  245. package/dist/types/src/plugin.workerd.d.ts.map +1 -0
  246. package/dist/types/src/skills/index.d.ts +2 -0
  247. package/dist/types/src/skills/index.d.ts.map +1 -0
  248. package/dist/types/src/skills/magazine-skill.d.ts +4 -0
  249. package/dist/types/src/skills/magazine-skill.d.ts.map +1 -0
  250. package/dist/types/src/stories/ArticleExtractor.stories.d.ts +267 -0
  251. package/dist/types/src/stories/ArticleExtractor.stories.d.ts.map +1 -0
  252. package/dist/types/src/stories/MagazineCurate.stories.d.ts +32 -0
  253. package/dist/types/src/stories/MagazineCurate.stories.d.ts.map +1 -0
  254. package/dist/types/src/stories/theregister-fixture.test.d.ts +2 -0
  255. package/dist/types/src/stories/theregister-fixture.test.d.ts.map +1 -0
  256. package/dist/types/src/templates/magazine-curation.d.ts +7 -0
  257. package/dist/types/src/templates/magazine-curation.d.ts.map +1 -0
  258. package/dist/types/src/testing/builder.d.ts +28 -0
  259. package/dist/types/src/testing/builder.d.ts.map +1 -0
  260. package/dist/types/src/testing/index.d.ts +2 -0
  261. package/dist/types/src/testing/index.d.ts.map +1 -0
  262. package/dist/types/src/translations.d.ts +247 -0
  263. package/dist/types/src/translations.d.ts.map +1 -0
  264. package/dist/types/src/types/CreateSubscription.d.ts +29 -0
  265. package/dist/types/src/types/CreateSubscription.d.ts.map +1 -0
  266. package/dist/types/src/types/FeedOperation.d.ts +52 -0
  267. package/dist/types/src/types/FeedOperation.d.ts.map +1 -0
  268. package/dist/types/src/types/Magazine.d.ts +87 -0
  269. package/dist/types/src/types/Magazine.d.ts.map +1 -0
  270. package/dist/types/src/types/Magazine.test.d.ts +2 -0
  271. package/dist/types/src/types/Magazine.test.d.ts.map +1 -0
  272. package/dist/types/src/types/Subscription.d.ts +144 -0
  273. package/dist/types/src/types/Subscription.d.ts.map +1 -0
  274. package/dist/types/src/types/Subscription.test.d.ts +2 -0
  275. package/dist/types/src/types/Subscription.test.d.ts.map +1 -0
  276. package/dist/types/src/types/index.d.ts +5 -0
  277. package/dist/types/src/types/index.d.ts.map +1 -0
  278. package/dist/types/src/util/date.d.ts +8 -0
  279. package/dist/types/src/util/date.d.ts.map +1 -0
  280. package/dist/types/src/util/index.d.ts +4 -0
  281. package/dist/types/src/util/index.d.ts.map +1 -0
  282. package/dist/types/src/util/post-content.d.ts +14 -0
  283. package/dist/types/src/util/post-content.d.ts.map +1 -0
  284. package/dist/types/src/util/text.d.ts +18 -0
  285. package/dist/types/src/util/text.d.ts.map +1 -0
  286. package/dist/types/tsconfig.tsbuildinfo +1 -0
  287. package/dx.config.ts +48 -0
  288. package/package.json +164 -0
  289. package/src/MagazinePlugin.test.ts +36 -0
  290. package/src/MagazinePlugin.tsx +61 -0
  291. package/src/MagazinePlugin.workerd.ts +31 -0
  292. package/src/atoms/atoms.test.ts +323 -0
  293. package/src/atoms/index.ts +10 -0
  294. package/src/atoms/magazine-posts.ts +70 -0
  295. package/src/atoms/post-content.ts +34 -0
  296. package/src/atoms/post-curation.ts +36 -0
  297. package/src/atoms/post-display.ts +59 -0
  298. package/src/atoms/post-read.ts +40 -0
  299. package/src/atoms/post-tags.ts +66 -0
  300. package/src/capabilities/app-graph-builder.ts +125 -0
  301. package/src/capabilities/create-object.ts +127 -0
  302. package/src/capabilities/index.ts +17 -0
  303. package/src/capabilities/navigation-resolver.ts +21 -0
  304. package/src/capabilities/operation-handler.ts +16 -0
  305. package/src/capabilities/react-surface.tsx +53 -0
  306. package/src/capabilities/routine-templates.ts +16 -0
  307. package/src/capabilities/skill-definition.ts +14 -0
  308. package/src/components/PostContent/PostContent.test.ts +81 -0
  309. package/src/components/PostContent/PostContent.tsx +111 -0
  310. package/src/components/PostContent/index.ts +5 -0
  311. package/src/components/PostStack/PostStack.stories.tsx +39 -0
  312. package/src/components/PostStack/PostStack.tsx +151 -0
  313. package/src/components/PostStack/index.ts +5 -0
  314. package/src/components/SubscriptionStack/SubscriptionStack.stories.tsx +52 -0
  315. package/src/components/SubscriptionStack/SubscriptionStack.tsx +161 -0
  316. package/src/components/SubscriptionStack/index.ts +5 -0
  317. package/src/components/index.ts +7 -0
  318. package/src/containers/FeedArticle/FeedArticle.stories.tsx +102 -0
  319. package/src/containers/FeedArticle/FeedArticle.tsx +68 -0
  320. package/src/containers/FeedArticle/FeedToolbar.tsx +47 -0
  321. package/src/containers/FeedArticle/index.ts +5 -0
  322. package/src/containers/FeedProperties/FeedProperties.tsx +107 -0
  323. package/src/containers/FeedProperties/index.ts +5 -0
  324. package/src/containers/MagazineArticle/MagazineArticle.stories.tsx +244 -0
  325. package/src/containers/MagazineArticle/MagazineArticle.tsx +146 -0
  326. package/src/containers/MagazineArticle/MagazineTile.tsx +88 -0
  327. package/src/containers/MagazineArticle/index.ts +5 -0
  328. package/src/containers/MagazineArticle/useToolbar.tsx +145 -0
  329. package/src/containers/PostArticle/PostArticle.stories.tsx +117 -0
  330. package/src/containers/PostArticle/PostArticle.tsx +140 -0
  331. package/src/containers/PostArticle/PostToolbar.tsx +120 -0
  332. package/src/containers/PostArticle/index.ts +5 -0
  333. package/src/containers/PostCard/PostCard.tsx +70 -0
  334. package/src/containers/PostCard/index.ts +5 -0
  335. package/src/containers/SubscriptionsArticle/SubscriptionsArticle.stories.tsx +80 -0
  336. package/src/containers/SubscriptionsArticle/SubscriptionsArticle.tsx +99 -0
  337. package/src/containers/SubscriptionsArticle/index.ts +5 -0
  338. package/src/containers/index.ts +11 -0
  339. package/src/index.ts +6 -0
  340. package/src/meta.ts +9 -0
  341. package/src/operations/clear-magazine.ts +83 -0
  342. package/src/operations/curate-magazine.skill.conversations.json +1 -0
  343. package/src/operations/curate-magazine.skill.test.ts +167 -0
  344. package/src/operations/curate-magazine.test.ts +91 -0
  345. package/src/operations/curate-magazine.ts +178 -0
  346. package/src/operations/extraction/article.test.ts +154 -0
  347. package/src/operations/extraction/article.ts +270 -0
  348. package/src/operations/extraction/index.ts +5 -0
  349. package/src/operations/fetch-article-content.ts +27 -0
  350. package/src/operations/index.ts +13 -0
  351. package/src/operations/load-post-content.ts +76 -0
  352. package/src/operations/sources/article.ts +122 -0
  353. package/src/operations/sources/cors.ts +19 -0
  354. package/src/operations/sources/feed-fetcher.test.ts +200 -0
  355. package/src/operations/sources/feed-fetcher.ts +26 -0
  356. package/src/operations/sources/http.ts +42 -0
  357. package/src/operations/sources/index.ts +9 -0
  358. package/src/operations/sources/rss.test.ts +201 -0
  359. package/src/operations/sources/rss.ts +138 -0
  360. package/src/operations/sources/standard-site.ts +381 -0
  361. package/src/operations/sources/testing/feed.xml +477 -0
  362. package/src/operations/sync-feed.test.ts +146 -0
  363. package/src/operations/sync-feed.ts +140 -0
  364. package/src/operations/util.test.ts +67 -0
  365. package/src/operations/util.ts +65 -0
  366. package/src/paths.ts +14 -0
  367. package/src/plugin.ts +11 -0
  368. package/src/plugin.workerd.ts +6 -0
  369. package/src/skills/index.ts +5 -0
  370. package/src/skills/magazine-skill.ts +45 -0
  371. package/src/stories/ArticleExtractor.stories.tsx +204 -0
  372. package/src/stories/MagazineCurate.stories.tsx +217 -0
  373. package/src/stories/fixtures/theregister-ai.xml +45 -0
  374. package/src/stories/theregister-fixture.test.ts +35 -0
  375. package/src/templates/magazine-curation.ts +47 -0
  376. package/src/testing/builder.ts +77 -0
  377. package/src/testing/index.ts +5 -0
  378. package/src/translations.ts +81 -0
  379. package/src/types/CreateSubscription.ts +61 -0
  380. package/src/types/FeedOperation.ts +134 -0
  381. package/src/types/Magazine.test.ts +69 -0
  382. package/src/types/Magazine.ts +214 -0
  383. package/src/types/Subscription.test.ts +171 -0
  384. package/src/types/Subscription.ts +366 -0
  385. package/src/types/index.ts +8 -0
  386. package/src/util/date.ts +27 -0
  387. package/src/util/index.ts +7 -0
  388. package/src/util/post-content.ts +19 -0
  389. package/src/util/text.ts +98 -0
  390. package/src/vite-env.d.ts +10 -0
@@ -0,0 +1,167 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ // Run in the node environment (not the package-default happy-dom): the agent's outbound Anthropic
6
+ // call goes through real `fetch`; happy-dom's fetch enforces CORS and blocks the cross-origin request.
7
+ // extractArticle falls back to defuddle/node (linkedom) when DOMParser is absent, so the real
8
+ // fetchArticleContent tool works here without a DOM polyfill.
9
+ // @vitest-environment node
10
+
11
+ import { describe, it } from '@effect/vitest';
12
+ import * as Effect from 'effect/Effect';
13
+ import { afterEach, beforeEach, vi } from 'vitest';
14
+
15
+ import { AgentHandlers } from '@dxos/assistant-toolkit';
16
+ import { Operation } from '@dxos/compute';
17
+ import { Database, Feed, Obj, Ref, Tag } from '@dxos/echo';
18
+ import { TestHelpers } from '@dxos/effect/testing';
19
+ import { AssistantTestLayer } from '@dxos/functions-runtime/testing';
20
+ import { EntityId } from '@dxos/keys';
21
+ import { StateMap, TagIndex, Text } from '@dxos/schema';
22
+
23
+ import { MagazineSkill } from '../skills';
24
+ import { FeedOperation, Magazine, Subscription } from '../types';
25
+ import { MagazineOperationHandlerSet } from './index';
26
+
27
+ EntityId.dangerouslyDisableRandomness();
28
+
29
+ const TestLayer = AssistantTestLayer({
30
+ // MagazineOperationHandlerSet provides CurateMagazine + SyncFeed + FetchArticleContent (the skill
31
+ // tool); AgentHandlers provides the RunInstructions handler the curate operation invokes.
32
+ operationHandlers: [MagazineOperationHandlerSet, AgentHandlers],
33
+ types: [
34
+ Feed.Feed,
35
+ Subscription.Subscription,
36
+ Subscription.Post,
37
+ Magazine.Magazine,
38
+ Tag.Tag,
39
+ Text.Text,
40
+ StateMap.StateMap,
41
+ TagIndex.TagIndex,
42
+ ],
43
+ skills: [MagazineSkill.make()],
44
+ });
45
+
46
+ /**
47
+ * Candidate posts seeded into the subscription's backing queue: two clearly on-topic for a
48
+ * space/astronomy magazine, two clearly off-topic.
49
+ */
50
+ const CANDIDATES = [
51
+ {
52
+ guid: 'artemis-ii',
53
+ title: "NASA's Artemis II crew completes lunar flyby rehearsal",
54
+ description:
55
+ 'The four astronauts ran a full simulation of the crewed Moon flyby, practicing re-entry and recovery procedures ahead of next year’s launch.',
56
+ onTopic: true,
57
+ },
58
+ {
59
+ guid: 'jwst-galaxy',
60
+ title: 'James Webb telescope confirms the most distant galaxy yet seen',
61
+ description:
62
+ 'Astronomers used JWST spectroscopy to confirm a galaxy as it appeared 290 million years after the Big Bang, a new record for cosmic distance.',
63
+ onTopic: true,
64
+ },
65
+ {
66
+ guid: 'retail-earnings',
67
+ title: 'Retail chain beats quarterly earnings estimates',
68
+ description:
69
+ 'Shares rose 6% after the company reported stronger-than-expected holiday sales and raised its full-year profit guidance.',
70
+ onTopic: false,
71
+ },
72
+ {
73
+ guid: 'js-bundler',
74
+ title: 'New JavaScript build tool promises 10x faster bundling',
75
+ description:
76
+ 'The Rust-based bundler aims to replace existing toolchains for large web applications with incremental compilation.',
77
+ onTopic: false,
78
+ },
79
+ ];
80
+
81
+ /** Minimal article page so the real extractArticle (defuddle) has a body to parse. */
82
+ const articleHtml = (title: string, body: string) =>
83
+ `<!doctype html><html><head><title>${title}</title></head><body><article><h1>${title}</h1><p>${body}</p></article></body></html>`;
84
+
85
+ // Preserve the real fetch so the stub can pass the agent's Anthropic call through (replay is
86
+ // memoized, so no real call is made then; generation uses the live API key).
87
+ // eslint-disable-next-line no-restricted-globals
88
+ const realFetch = globalThis.fetch.bind(globalThis);
89
+
90
+ describe('CurateMagazine (LLM)', () => {
91
+ // Stub only the network boundary: serve canned HTML for the candidate article URLs (so the real
92
+ // fetchArticleContent tool runs deterministically), and pass everything else (Anthropic) through.
93
+ beforeEach(() => {
94
+ vi.stubGlobal(
95
+ 'fetch',
96
+ vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
97
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
98
+ const candidate = CANDIDATES.find((entry) => url.includes(entry.guid));
99
+ if (candidate) {
100
+ return new Response(articleHtml(candidate.title, candidate.description), {
101
+ status: 200,
102
+ headers: { 'content-type': 'text/html' },
103
+ });
104
+ }
105
+ return realFetch(input, init);
106
+ }),
107
+ );
108
+ });
109
+
110
+ afterEach(() => {
111
+ vi.unstubAllGlobals();
112
+ });
113
+
114
+ it.effect(
115
+ 'selects only the posts matching the magazine topic',
116
+ Effect.fnUntraced(
117
+ function* ({ expect }) {
118
+ // Subscription with no `url` → the SyncFeed phase is skipped (no network); curation reads the
119
+ // posts we seed directly into the backing queue below.
120
+ const subscription = yield* Database.add(
121
+ Subscription.makeSubscription({ name: 'Tech & Science Daily', type: 'rss' }),
122
+ );
123
+ yield* Database.flush();
124
+
125
+ const postFeed = yield* Database.load(subscription.feed);
126
+ const posts = CANDIDATES.map((candidate) =>
127
+ Obj.make(Subscription.Post, {
128
+ title: candidate.title,
129
+ description: candidate.description,
130
+ link: `https://example.com/${candidate.guid}`,
131
+ guid: candidate.guid,
132
+ published: '2026-05-01T00:00:00Z',
133
+ source: Ref.make(subscription),
134
+ }),
135
+ );
136
+ yield* Feed.append(postFeed, posts);
137
+ yield* Database.flush();
138
+
139
+ const magazine = yield* Database.add(
140
+ Magazine.make({
141
+ name: 'The Cosmos',
142
+ feeds: [Ref.make(subscription)],
143
+ instructions:
144
+ 'Curate articles about space exploration and astronomy — missions, spacecraft, telescopes, and astrophysics discoveries. Exclude unrelated business, finance, or software-tooling news.',
145
+ }),
146
+ );
147
+ yield* Database.flush();
148
+
149
+ const result = yield* Operation.invoke(FeedOperation.CurateMagazine, { magazine: Ref.make(magazine) });
150
+
151
+ // Load the curated post refs to read their titles (`ref.target` is not hydrated synchronously).
152
+ const curatedPosts = yield* Effect.promise(() => Promise.all(magazine.posts.map((ref) => ref.load())));
153
+ const curatedTitles = curatedPosts.map((post) => post.title);
154
+
155
+ // The two space/astronomy posts are curated; the business + tooling posts are not.
156
+ expect(curatedTitles).toContain(CANDIDATES[0].title);
157
+ expect(curatedTitles).toContain(CANDIDATES[1].title);
158
+ expect(curatedTitles).not.toContain(CANDIDATES[2].title);
159
+ expect(curatedTitles).not.toContain(CANDIDATES[3].title);
160
+ expect(result.curated).toBe(2);
161
+ },
162
+ Effect.provide(TestLayer),
163
+ TestHelpers.provideTestContext,
164
+ ),
165
+ { timeout: 240_000 },
166
+ );
167
+ });
@@ -0,0 +1,91 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { afterEach, beforeEach, describe, expect, test } from 'vitest';
6
+
7
+ import { Feed, Obj, Ref, Tag } from '@dxos/echo';
8
+ import { EchoTestBuilder } from '@dxos/echo-client/testing';
9
+ import { StateMap, TagIndex, Text } from '@dxos/schema';
10
+
11
+ import { Magazine, Subscription } from '../types';
12
+ import { applyKeep, resolveSelected } from './curate-magazine';
13
+
14
+ describe('applyKeep', () => {
15
+ let builder: EchoTestBuilder;
16
+
17
+ beforeEach(async () => {
18
+ builder = await new EchoTestBuilder().open();
19
+ });
20
+
21
+ afterEach(async () => {
22
+ await builder.close();
23
+ });
24
+
25
+ const setup = async () => {
26
+ const { db } = await builder.createDatabase({
27
+ types: [
28
+ Feed.Feed,
29
+ Subscription.Subscription,
30
+ Subscription.Post,
31
+ Magazine.Magazine,
32
+ Tag.Tag,
33
+ Text.Text,
34
+ StateMap.StateMap,
35
+ TagIndex.TagIndex,
36
+ ],
37
+ });
38
+ return { db };
39
+ };
40
+
41
+ const makePost = (published: string): Subscription.Post =>
42
+ Obj.make(Subscription.Post, { title: `Post ${published}`, description: 'body', published });
43
+
44
+ test('keeps the newest posts up to the bound', async () => {
45
+ const { db } = await setup();
46
+ const posts = ['2026-01-01', '2026-01-02', '2026-01-03'].map((date) =>
47
+ Ref.make(db.add(makePost(`${date}T00:00:00Z`))),
48
+ );
49
+
50
+ const kept = applyKeep(posts, 2, undefined);
51
+ const keptDates = kept.map((ref) => ref.target?.published);
52
+ expect(kept).toHaveLength(2);
53
+ expect(keptDates).toContain('2026-01-03T00:00:00Z');
54
+ expect(keptDates).toContain('2026-01-02T00:00:00Z');
55
+ expect(keptDates).not.toContain('2026-01-01T00:00:00Z');
56
+ });
57
+
58
+ test('no-ops when within the bound', async () => {
59
+ const { db } = await setup();
60
+ const posts = ['2026-01-01', '2026-01-02'].map((date) => Ref.make(db.add(makePost(`${date}T00:00:00Z`))));
61
+ expect(applyKeep(posts, 10, undefined)).toHaveLength(2);
62
+ });
63
+ });
64
+
65
+ describe('resolveSelected', () => {
66
+ let builder: EchoTestBuilder;
67
+
68
+ beforeEach(async () => {
69
+ builder = await new EchoTestBuilder().open();
70
+ });
71
+
72
+ afterEach(async () => {
73
+ await builder.close();
74
+ });
75
+
76
+ test('maps ids to posts in order, dropping unknown and duplicate ids', async () => {
77
+ const { db } = await builder.createDatabase({ types: [Subscription.Post] });
78
+ const posts = ['a', 'b', 'c'].map((title) => db.add(Obj.make(Subscription.Post, { title })));
79
+ await db.flush();
80
+ const candidates = posts.map((post) => ({ post }));
81
+
82
+ const selected = resolveSelected(candidates, [
83
+ { id: posts[2].id },
84
+ { id: 'missing-id' },
85
+ { id: posts[0].id },
86
+ { id: posts[2].id },
87
+ ]);
88
+
89
+ expect(selected.map(({ post }) => post.id)).toEqual([posts[2].id, posts[0].id]);
90
+ });
91
+ });
@@ -0,0 +1,178 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Effect from 'effect/Effect';
6
+ import * as Option from 'effect/Option';
7
+ import * as Schema from 'effect/Schema';
8
+
9
+ import { RunInstructions } from '@dxos/assistant-toolkit';
10
+ import { Operation } from '@dxos/compute';
11
+ import { Database, Obj, Ref } from '@dxos/echo';
12
+ import { type EntityId, type SpaceId } from '@dxos/keys';
13
+ import { log } from '@dxos/log';
14
+
15
+ import { FeedOperation, Magazine, Subscription } from '../types';
16
+ import { collectCandidates, partitionByKeepBound } from './util';
17
+
18
+ export default FeedOperation.CurateMagazine.pipe(
19
+ Operation.withHandler(
20
+ Effect.fnUntraced(function* ({ magazine: magazineRef }) {
21
+ const magazine = yield* Effect.promise(() => magazineRef.load());
22
+
23
+ const validFeeds = yield* loadValidFeeds(magazine);
24
+ const synced = yield* syncFeeds(validFeeds);
25
+
26
+ // Select matching Posts via the agent (single-shot structured output), then add them mechanically.
27
+ const candidates = yield* collectCandidates(magazine);
28
+ const spaceId = Obj.getDatabase(magazine)?.spaceId;
29
+ const selectedEntries =
30
+ candidates.length > 0 && spaceId ? yield* selectPostIds(magazine, candidates, spaceId) : [];
31
+ const selected = resolveSelected(candidates, selectedEntries);
32
+
33
+ // Build the next posts list as a pure function of (existing curated + newly selected), bounded
34
+ // by the magazine's `keep`, then commit it in one update. collectCandidates already excludes
35
+ // posts already curated, so the additions are simply appended (resolveSelected deduped them).
36
+ const db = Obj.getDatabase(magazine);
37
+ const starredUri = db ? yield* Effect.promise(() => Subscription.findSystemTagUri(db, 'starred')) : undefined;
38
+ const merged = [...magazine.posts, ...selected.map(({ post }) => Ref.make(post))];
39
+ const nextPosts = applyKeep(merged, magazine.keep ?? Subscription.DEFAULT_KEEP, starredUri);
40
+ const curated = selected.length;
41
+
42
+ const changed =
43
+ nextPosts.length !== magazine.posts.length ||
44
+ nextPosts.some((ref, index) => ref.uri !== magazine.posts[index]?.uri);
45
+ if (changed) {
46
+ Obj.update(magazine, (magazine) => {
47
+ magazine.posts = nextPosts;
48
+ });
49
+ }
50
+
51
+ // Write agent-generated snippet/imageUrl into per-post magazine state.
52
+ for (const { post, snippet, imageUrl } of selected) {
53
+ if (snippet || imageUrl) {
54
+ Magazine.patchPostState(magazine, post.id as EntityId, { snippet, imageUrl });
55
+ }
56
+ }
57
+
58
+ return { synced, curated };
59
+ }),
60
+ ),
61
+ Operation.opaqueHandler,
62
+ );
63
+
64
+ // -- Helpers --
65
+
66
+ /** Bound on concurrent feed syncs. */
67
+ const SYNC_CONCURRENCY = 8;
68
+
69
+ /** Loads each referenced feed (and its backing ECHO feed), tolerating individual failures, keeping only syncable feeds. */
70
+ const loadValidFeeds = (magazine: Magazine.Magazine) =>
71
+ Effect.forEach(magazine.feeds, (ref) =>
72
+ Effect.gen(function* () {
73
+ const feed = yield* Database.load(ref);
74
+ if (feed.feed) {
75
+ yield* Database.load(feed.feed); // Hydrate the backing ECHO feed.
76
+ }
77
+ return feed;
78
+ }).pipe(
79
+ Effect.tapError((error) => Effect.sync(() => log.catch(error))),
80
+ Effect.option,
81
+ ),
82
+ ).pipe(Effect.map((feeds) => feeds.flatMap(Option.toArray).filter((feed) => Boolean(feed.url))));
83
+
84
+ /** Syncs all feeds in parallel, tolerating per-feed failures; resolves to the count synced successfully. */
85
+ const syncFeeds = (validFeeds: readonly Subscription.Subscription[]) =>
86
+ Effect.forEach(
87
+ validFeeds,
88
+ (feed) =>
89
+ Operation.invoke(
90
+ FeedOperation.SyncFeed,
91
+ { feed: Ref.make(feed) },
92
+ { spaceId: Obj.getDatabase(feed)?.spaceId },
93
+ ).pipe(
94
+ Effect.as(true),
95
+ Effect.catchAll((error) => Effect.sync(() => (log.catch(error, { feedUrl: feed.url }), false))),
96
+ ),
97
+ { concurrency: SYNC_CONCURRENCY },
98
+ ).pipe(Effect.map((results) => results.filter(Boolean).length));
99
+
100
+ /**
101
+ * Runs the curation agent over the candidate summaries and resolves to the selected Post entries.
102
+ * The magazine's persisted Instructions (created with the magazine) carries the editorial brief and
103
+ * references the Magazine skill, which RunInstructions resolves at run time. No instructions → no selection.
104
+ * Tolerates agent/parse failures (logs → no selection).
105
+ */
106
+ const selectPostIds = (
107
+ magazine: Magazine.Magazine,
108
+ candidates: ReadonlyArray<{ post: Subscription.Post; feed: Subscription.Subscription }>,
109
+ spaceId: SpaceId,
110
+ ) =>
111
+ Effect.gen(function* () {
112
+ if (!magazine.instructions) {
113
+ return [] as readonly (typeof Magazine.CurationOutput.Type.posts)[number][];
114
+ }
115
+ const input = {
116
+ candidates: candidates.map(({ post, feed }) => ({
117
+ id: post.id,
118
+ feedName: feed.name,
119
+ title: post.title,
120
+ description: post.description,
121
+ author: post.author,
122
+ published: post.published,
123
+ link: post.link,
124
+ })),
125
+ };
126
+
127
+ return yield* Operation.invoke(RunInstructions, { instructions: magazine.instructions, input }, { spaceId }).pipe(
128
+ Effect.flatMap(Schema.decodeUnknown(Magazine.CurationOutput)),
129
+ Effect.map((output) => output.posts),
130
+ Effect.catchAll((error) =>
131
+ Effect.sync(() => {
132
+ log.warn('curation selection failed', { error });
133
+ return [] as readonly (typeof Magazine.CurationOutput.Type.posts)[number][];
134
+ }),
135
+ ),
136
+ );
137
+ });
138
+
139
+ type SelectedEntry = {
140
+ post: Subscription.Post;
141
+ snippet: string | undefined;
142
+ imageUrl: string | undefined;
143
+ };
144
+
145
+ /** Resolves the agent's selected entries back to candidate Posts, preserving order and dropping unknown/duplicate ids. */
146
+ export const resolveSelected = (
147
+ candidates: ReadonlyArray<{ post: Subscription.Post }>,
148
+ entries: readonly { id: string; snippet?: string; imageUrl?: string }[],
149
+ ): SelectedEntry[] => {
150
+ const byId = new Map(candidates.map(({ post }) => [post.id, post]));
151
+ const seen = new Set<string>();
152
+ const selected: SelectedEntry[] = [];
153
+ for (const { id, snippet, imageUrl } of entries) {
154
+ const post = byId.get(id);
155
+ if (post && !seen.has(id)) {
156
+ seen.add(id);
157
+ selected.push({ post, snippet, imageUrl });
158
+ }
159
+ }
160
+ return selected;
161
+ };
162
+
163
+ /**
164
+ * Bounds a curated posts ref list to `keep` total (non-starred) posts: keeps all starred posts and
165
+ * unresolved refs, plus the `keep` newest non-starred by published date. Pure; returns the retained
166
+ * refs. Delegates the sort/slice/starred partition to {@link partitionByKeepBound}.
167
+ */
168
+ export const applyKeep = (
169
+ posts: readonly Ref.Ref<Subscription.Post>[],
170
+ keep: number,
171
+ starredUri: string | undefined,
172
+ ): Ref.Ref<Subscription.Post>[] => {
173
+ const isStarred = (post: Subscription.Post) => Subscription.hasTag(post.source?.target, post.id, starredUri);
174
+ const resolved = posts.map((ref) => ref.target).filter((post): post is Subscription.Post => post !== undefined);
175
+ const unresolved = posts.filter((ref) => !ref.target);
176
+ const { kept } = partitionByKeepBound(resolved, keep, isStarred);
177
+ return [...kept.map((post) => Ref.make(post)), ...unresolved];
178
+ };
@@ -0,0 +1,154 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, expect, test } from 'vitest';
6
+
7
+ import { extractArticle, pruneTrailingChrome } from './article';
8
+
9
+ const parse = (html: string): Document => new DOMParser().parseFromString(html, 'text/html');
10
+
11
+ describe('extractArticle', () => {
12
+ test('returns empty result for empty input', async () => {
13
+ const result = await extractArticle('');
14
+ expect(result.markdown).toBe('');
15
+ expect(result.imageUrls).toEqual([]);
16
+ });
17
+
18
+ test('strips trailing tag-cloud (heading + /Tag/ link list)', async () => {
19
+ // theregister.com shape: a "Narrower topics" / "Broader topics" heading
20
+ // followed by a `<ul>` of links to /Tag/* routes inside the article.
21
+ const html = `
22
+ <html><head><title>Test</title></head>
23
+ <body>
24
+ <article>
25
+ <h1>The article</h1>
26
+ <p>Body paragraph one with substantive text so defuddle treats this as an article. Lorem ipsum dolor sit amet.</p>
27
+ <p>Body paragraph two. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium.</p>
28
+ <p>Closing line.</p>
29
+ <h3>Narrower topics</h3>
30
+ <ul>
31
+ <li><a href="/Tag/AIOps/">AIOps</a></li>
32
+ <li><a href="/Tag/Anthropic/">Anthropic</a></li>
33
+ <li><a href="/Tag/ChatGPT/">ChatGPT</a></li>
34
+ </ul>
35
+ <h3>Broader topics</h3>
36
+ <ul>
37
+ <li><a href="/Tag/Self-driving/">Self-driving</a></li>
38
+ </ul>
39
+ </article>
40
+ </body></html>
41
+ `;
42
+ const result = await extractArticle(html, 'https://example.com/test');
43
+ expect(result.markdown).not.toMatch(/Narrower topics/i);
44
+ expect(result.markdown).not.toMatch(/Broader topics/i);
45
+ expect(result.markdown).not.toMatch(/AIOps/);
46
+ expect(result.markdown).not.toMatch(/Self-driving/);
47
+ expect(result.markdown).toMatch(/Body paragraph one/);
48
+ expect(result.markdown).toMatch(/Closing line/);
49
+ });
50
+
51
+ test('strips trailing block by class hint even without a giveaway heading', async () => {
52
+ // No "Narrower topics"-style label — the trailing block is recognised
53
+ // by its `class="related-articles"` hint alone. The text-pattern trim
54
+ // could not catch this; the DOM rules can.
55
+ const html = `
56
+ <html><body>
57
+ <article>
58
+ <h1>Essay</h1>
59
+ <p>Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur.</p>
60
+ <p>Final paragraph of the body. Vel illum qui dolorem eum fugiat quo voluptas nulla pariatur.</p>
61
+ <div class="related-articles">
62
+ <a href="/articles/abc">Another piece</a>
63
+ <a href="/articles/def">Yet another</a>
64
+ <a href="/articles/ghi">A third</a>
65
+ </div>
66
+ </article>
67
+ </body></html>
68
+ `;
69
+ const result = await extractArticle(html, 'https://example.com/essay');
70
+ expect(result.markdown).not.toMatch(/Another piece/);
71
+ expect(result.markdown).not.toMatch(/Yet another/);
72
+ expect(result.markdown).toMatch(/Final paragraph of the body/);
73
+ });
74
+
75
+ test('strips trailing <aside> nested inside the article', async () => {
76
+ const html = `
77
+ <html><body>
78
+ <main>
79
+ <h1>News piece</h1>
80
+ <p>News body. At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum.</p>
81
+ <p>More body. Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus.</p>
82
+ <aside class="more-from-author">
83
+ <h3>More from this author</h3>
84
+ <ul><li><a href="/by/jane/1">One</a></li><li><a href="/by/jane/2">Two</a></li></ul>
85
+ </aside>
86
+ </main>
87
+ </body></html>
88
+ `;
89
+ const result = await extractArticle(html, 'https://example.com/news');
90
+ expect(result.markdown).not.toMatch(/More from this author/i);
91
+ expect(result.markdown).toMatch(/News body/);
92
+ });
93
+
94
+ test('pruneTrailingChrome preserves a heading that is naturally last (no chrome removed before it)', () => {
95
+ // Direct DOM test — defuddle's later passes may independently drop a
96
+ // lone trailing heading, so we assert against the post-prune DOM rather
97
+ // than the final markdown to isolate the pruner's guard.
98
+ const doc = parse(`
99
+ <html><body>
100
+ <article>
101
+ <h1>The piece</h1>
102
+ <p>Body paragraph.</p>
103
+ <h2>Conclusion</h2>
104
+ </article>
105
+ </body></html>
106
+ `);
107
+ pruneTrailingChrome(doc);
108
+ const article = doc.querySelector('article')!;
109
+ expect(article.querySelector('h2')?.textContent).toBe('Conclusion');
110
+ expect(article.querySelectorAll('h2,p').length).toBe(2);
111
+ });
112
+
113
+ test('pruneTrailingChrome strips the heading paired with a chrome block it titles', () => {
114
+ const doc = parse(`
115
+ <html><body>
116
+ <article>
117
+ <h1>The piece</h1>
118
+ <p>Body paragraph.</p>
119
+ <h2>Narrower topics</h2>
120
+ <ul>
121
+ <li><a href="/Tag/AIOps/">AIOps</a></li>
122
+ <li><a href="/Tag/Anthropic/">Anthropic</a></li>
123
+ </ul>
124
+ </article>
125
+ </body></html>
126
+ `);
127
+ pruneTrailingChrome(doc);
128
+ const article = doc.querySelector('article')!;
129
+ expect(article.querySelector('ul')).toBeNull();
130
+ expect(article.querySelector('h2')).toBeNull();
131
+ expect(article.querySelectorAll('p').length).toBe(1);
132
+ });
133
+
134
+ test('does not strip a "Topics" section in the middle of the article', async () => {
135
+ // A section titled "Topics" appears mid-article followed by another body
136
+ // section. Trailing-chrome pruning must leave it alone.
137
+ const html = `
138
+ <html><body>
139
+ <article>
140
+ <h1>An essay</h1>
141
+ <p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.</p>
142
+ <h2>Topics</h2>
143
+ <p>Here are the topics this essay covers in prose form. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip.</p>
144
+ <h2>Conclusion</h2>
145
+ <p>That wraps it up. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.</p>
146
+ </article>
147
+ </body></html>
148
+ `;
149
+ const result = await extractArticle(html, 'https://example.com/essay');
150
+ expect(result.markdown).toMatch(/Topics/);
151
+ expect(result.markdown).toMatch(/Conclusion/);
152
+ expect(result.markdown).toMatch(/That wraps it up/);
153
+ });
154
+ });