trending-news-api 0.1.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.
- package/README.md +99 -0
- package/dist/index.cjs +283 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +66 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +252 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
package/README.md
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# trending-news-api
|
|
2
|
+
|
|
3
|
+
React trending news widget: daily top Wikipedia pages (via the Wikimedia Pageviews API) matched
|
|
4
|
+
against headlines from [The News API](https://www.thenewsapi.com/), served through a bundled
|
|
5
|
+
Cloudflare Worker so the News API key never reaches the browser.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- Daily trending topics, ranked by Wikipedia pageviews.
|
|
10
|
+
- Matching headlines per topic from The News API.
|
|
11
|
+
- Single-topic headline lookup.
|
|
12
|
+
- Compact card row or full article-list layouts.
|
|
13
|
+
- `localStorage` response caching (10 minutes).
|
|
14
|
+
- TypeScript + tsup library scaffold.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install trending-news-api
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```tsx
|
|
25
|
+
import { TrendingNews } from 'trending-news-api';
|
|
26
|
+
|
|
27
|
+
export default function App() {
|
|
28
|
+
return (
|
|
29
|
+
<TrendingNews
|
|
30
|
+
compact
|
|
31
|
+
maxTopics={8}
|
|
32
|
+
apiEndpoint="https://trending-news-api.your-subdomain.workers.dev"
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Pass `topic` to render headlines for a single topic instead of the daily trending list:
|
|
39
|
+
|
|
40
|
+
```tsx
|
|
41
|
+
<TrendingNews apiEndpoint="https://trending-news-api.your-subdomain.workers.dev" topic="Donald Trump" />
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
The widget renders nothing when `apiEndpoint` is unset, still loading, or errored — safe to drop
|
|
45
|
+
into a layout unconditionally.
|
|
46
|
+
|
|
47
|
+
## Direct API usage
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { getTrendingNews, getTrendingNewsForTopic } from 'trending-news-api';
|
|
51
|
+
|
|
52
|
+
const trending = await getTrendingNews({
|
|
53
|
+
apiEndpoint: 'https://trending-news-api.your-subdomain.workers.dev',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const topicNews = await getTrendingNewsForTopic('Donald Trump', {
|
|
57
|
+
apiEndpoint: 'https://trending-news-api.your-subdomain.workers.dev',
|
|
58
|
+
});
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Build
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install
|
|
65
|
+
npm run build
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## The worker backend
|
|
69
|
+
|
|
70
|
+
`worker/index.ts` is a Cloudflare Worker that:
|
|
71
|
+
|
|
72
|
+
- Fetches yesterday's top Wikipedia pages by pageviews (Wikimedia REST API), filtering out
|
|
73
|
+
non-article pages (`Main_Page`, `Special:`, `Wikipedia:`, etc).
|
|
74
|
+
- For each page, searches The News API for matching headlines.
|
|
75
|
+
- Exposes:
|
|
76
|
+
- `GET /` — trending topics with headline counts and articles.
|
|
77
|
+
- `GET /?topic=...` — headlines for a specific topic.
|
|
78
|
+
|
|
79
|
+
### Deploying the worker
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
cd packages/trending-news-api
|
|
83
|
+
npm run worker:deploy
|
|
84
|
+
npx wrangler secret put THENEWSAPI_API_KEY --config worker/wrangler.jsonc
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Use the resulting `*.workers.dev` URL (or a custom route) as `apiEndpoint`.
|
|
88
|
+
|
|
89
|
+
## Caching
|
|
90
|
+
|
|
91
|
+
`getTrendingNews` / `getTrendingNewsForTopic` cache each response in `localStorage` for 10
|
|
92
|
+
minutes, keyed by the exact request URL. Call `clearTrendingNewsCache()` to evict everything
|
|
93
|
+
(e.g. in tests). The cache is a no-op in non-browser environments (SSR) or when `localStorage`
|
|
94
|
+
is unavailable/full.
|
|
95
|
+
|
|
96
|
+
## Notes
|
|
97
|
+
|
|
98
|
+
- Wikimedia's Pageviews API powers the trending topic list.
|
|
99
|
+
- The News API (thenewsapi.com) powers the headlines — you'll need a free or paid API key.
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
TrendingNews: () => TrendingNews,
|
|
24
|
+
clearTrendingNewsCache: () => clearTrendingNewsCache,
|
|
25
|
+
getTrendingNews: () => getTrendingNews,
|
|
26
|
+
getTrendingNewsForTopic: () => getTrendingNewsForTopic,
|
|
27
|
+
useTrendingNews: () => useTrendingNews
|
|
28
|
+
});
|
|
29
|
+
module.exports = __toCommonJS(index_exports);
|
|
30
|
+
|
|
31
|
+
// src/lib/cache.ts
|
|
32
|
+
var CACHE_PREFIX = "trending-news-cache:";
|
|
33
|
+
var CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
34
|
+
function getStorage() {
|
|
35
|
+
if (typeof window === "undefined" || !window.localStorage) return null;
|
|
36
|
+
return window.localStorage;
|
|
37
|
+
}
|
|
38
|
+
function readCachedTrendingNews(key) {
|
|
39
|
+
const storage = getStorage();
|
|
40
|
+
if (!storage) return null;
|
|
41
|
+
try {
|
|
42
|
+
const raw = storage.getItem(CACHE_PREFIX + key);
|
|
43
|
+
if (!raw) return null;
|
|
44
|
+
const entry = JSON.parse(raw);
|
|
45
|
+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
|
46
|
+
storage.removeItem(CACHE_PREFIX + key);
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
return entry.data;
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
function writeCachedTrendingNews(key, data) {
|
|
55
|
+
const storage = getStorage();
|
|
56
|
+
if (!storage) return;
|
|
57
|
+
try {
|
|
58
|
+
const entry = { timestamp: Date.now(), data };
|
|
59
|
+
storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));
|
|
60
|
+
} catch {
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function clearTrendingNewsCache() {
|
|
64
|
+
const storage = getStorage();
|
|
65
|
+
if (!storage) return;
|
|
66
|
+
for (let i = storage.length - 1; i >= 0; i--) {
|
|
67
|
+
const key = storage.key(i);
|
|
68
|
+
if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/api/trending.ts
|
|
73
|
+
function mapArticles(articles = []) {
|
|
74
|
+
return articles.map((a) => ({
|
|
75
|
+
title: a.title ?? "",
|
|
76
|
+
url: a.url,
|
|
77
|
+
source: a.source,
|
|
78
|
+
publishedAt: a.published_at
|
|
79
|
+
}));
|
|
80
|
+
}
|
|
81
|
+
function buildUrl(apiEndpoint, options) {
|
|
82
|
+
const url = new URL(apiEndpoint);
|
|
83
|
+
if (options.topic) url.searchParams.set("topic", options.topic);
|
|
84
|
+
return url.toString();
|
|
85
|
+
}
|
|
86
|
+
async function getTrendingNews(options) {
|
|
87
|
+
if (!options.apiEndpoint) {
|
|
88
|
+
throw new Error("trending-news-api: apiEndpoint is required");
|
|
89
|
+
}
|
|
90
|
+
const url = buildUrl(options.apiEndpoint, options);
|
|
91
|
+
const cached = readCachedTrendingNews(url);
|
|
92
|
+
if (cached) return cached;
|
|
93
|
+
const response = await fetch(url, { headers: { "Content-Type": "application/json" } });
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);
|
|
96
|
+
}
|
|
97
|
+
const data = await response.json();
|
|
98
|
+
if (data.error) throw new Error(data.error);
|
|
99
|
+
const limit = options.limit ?? 25;
|
|
100
|
+
const result = {
|
|
101
|
+
date: data.date,
|
|
102
|
+
topics: (data.topics ?? []).slice(0, limit).map((t) => ({
|
|
103
|
+
topic: t.topic,
|
|
104
|
+
wikiRank: t.wiki_rank,
|
|
105
|
+
wikiViews: t.wiki_views,
|
|
106
|
+
newsCount: t.news_count,
|
|
107
|
+
articles: mapArticles(t.articles)
|
|
108
|
+
}))
|
|
109
|
+
};
|
|
110
|
+
writeCachedTrendingNews(url, result);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
async function getTrendingNewsForTopic(topic, options) {
|
|
114
|
+
if (!options.apiEndpoint) {
|
|
115
|
+
throw new Error("trending-news-api: apiEndpoint is required");
|
|
116
|
+
}
|
|
117
|
+
const url = buildUrl(options.apiEndpoint, { ...options, topic });
|
|
118
|
+
const cached = readCachedTrendingNews(url);
|
|
119
|
+
if (cached) return cached;
|
|
120
|
+
const response = await fetch(url, { headers: { "Content-Type": "application/json" } });
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);
|
|
123
|
+
}
|
|
124
|
+
const data = await response.json();
|
|
125
|
+
if (data.error) throw new Error(data.error);
|
|
126
|
+
const result = {
|
|
127
|
+
topic: data.topic,
|
|
128
|
+
newsCount: data.news_count,
|
|
129
|
+
articles: mapArticles(data.articles)
|
|
130
|
+
};
|
|
131
|
+
writeCachedTrendingNews(url, result);
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/hooks/useTrendingNews.ts
|
|
136
|
+
var import_react = require("react");
|
|
137
|
+
function useTrendingNews(options = {}) {
|
|
138
|
+
const [data, setData] = (0, import_react.useState)(null);
|
|
139
|
+
const [error, setError] = (0, import_react.useState)(null);
|
|
140
|
+
const [loading, setLoading] = (0, import_react.useState)(Boolean(options.apiEndpoint));
|
|
141
|
+
(0, import_react.useEffect)(() => {
|
|
142
|
+
if (!options.apiEndpoint) {
|
|
143
|
+
setLoading(false);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
let active = true;
|
|
147
|
+
setLoading(true);
|
|
148
|
+
setError(null);
|
|
149
|
+
getTrendingNews(options).then((result) => {
|
|
150
|
+
if (active) setData(result);
|
|
151
|
+
}).catch((err) => {
|
|
152
|
+
if (active) setError(err instanceof Error ? err : new Error("Unknown error"));
|
|
153
|
+
}).finally(() => {
|
|
154
|
+
if (active) setLoading(false);
|
|
155
|
+
});
|
|
156
|
+
return () => {
|
|
157
|
+
active = false;
|
|
158
|
+
};
|
|
159
|
+
}, [options.apiEndpoint, options.topic, options.limit]);
|
|
160
|
+
return { data, error, loading };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// src/components/TrendingNews.tsx
|
|
164
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
165
|
+
var styles = {
|
|
166
|
+
root: {
|
|
167
|
+
fontFamily: "system-ui, sans-serif",
|
|
168
|
+
border: "1px solid #e5e7eb",
|
|
169
|
+
borderRadius: 12,
|
|
170
|
+
padding: 16,
|
|
171
|
+
background: "#fff",
|
|
172
|
+
color: "#111827",
|
|
173
|
+
maxWidth: 640
|
|
174
|
+
},
|
|
175
|
+
compactRoot: {
|
|
176
|
+
fontFamily: "system-ui, sans-serif",
|
|
177
|
+
display: "flex",
|
|
178
|
+
flexDirection: "column",
|
|
179
|
+
gap: 6,
|
|
180
|
+
padding: "10px 14px",
|
|
181
|
+
borderRadius: 8
|
|
182
|
+
},
|
|
183
|
+
compactHeader: {
|
|
184
|
+
fontSize: 11,
|
|
185
|
+
fontWeight: 600,
|
|
186
|
+
opacity: 0.75,
|
|
187
|
+
letterSpacing: 0.3,
|
|
188
|
+
textTransform: "uppercase"
|
|
189
|
+
},
|
|
190
|
+
topicRow: { display: "flex", gap: 10, overflowX: "auto", paddingBottom: 2 },
|
|
191
|
+
topicCard: {
|
|
192
|
+
minWidth: 160,
|
|
193
|
+
maxWidth: 200,
|
|
194
|
+
padding: "8px 10px",
|
|
195
|
+
borderRadius: 8,
|
|
196
|
+
background: "rgba(255,255,255,0.06)",
|
|
197
|
+
display: "flex",
|
|
198
|
+
flexDirection: "column",
|
|
199
|
+
gap: 4,
|
|
200
|
+
textDecoration: "none",
|
|
201
|
+
color: "inherit"
|
|
202
|
+
},
|
|
203
|
+
topicName: {
|
|
204
|
+
fontSize: 12,
|
|
205
|
+
fontWeight: 600,
|
|
206
|
+
whiteSpace: "nowrap",
|
|
207
|
+
overflow: "hidden",
|
|
208
|
+
textOverflow: "ellipsis"
|
|
209
|
+
},
|
|
210
|
+
headline: {
|
|
211
|
+
fontSize: 11,
|
|
212
|
+
opacity: 0.8,
|
|
213
|
+
display: "-webkit-box",
|
|
214
|
+
WebkitLineClamp: 2,
|
|
215
|
+
WebkitBoxOrient: "vertical",
|
|
216
|
+
overflow: "hidden"
|
|
217
|
+
},
|
|
218
|
+
count: { fontSize: 10, opacity: 0.6 },
|
|
219
|
+
list: { display: "flex", flexDirection: "column", gap: 16 },
|
|
220
|
+
fullTopic: { display: "flex", flexDirection: "column", gap: 6 },
|
|
221
|
+
fullTopicHeader: { display: "flex", alignItems: "baseline", gap: 8 },
|
|
222
|
+
article: { fontSize: 13 },
|
|
223
|
+
articleLink: { color: "inherit", textDecoration: "none" }
|
|
224
|
+
};
|
|
225
|
+
function TrendingNews(props) {
|
|
226
|
+
const { className, style, compact, maxTopics = 8, ...options } = props;
|
|
227
|
+
const { data, loading, error } = useTrendingNews(options);
|
|
228
|
+
if (!options.apiEndpoint) return null;
|
|
229
|
+
if (loading && !data) return null;
|
|
230
|
+
if (error) return null;
|
|
231
|
+
if (!data || data.topics.length === 0) return null;
|
|
232
|
+
const topics = data.topics.slice(0, maxTopics);
|
|
233
|
+
if (compact) {
|
|
234
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className, style: { ...styles.compactRoot, ...style }, children: [
|
|
235
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.compactHeader, children: "Trending" }),
|
|
236
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.topicRow, children: topics.map((topic) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
|
|
237
|
+
"a",
|
|
238
|
+
{
|
|
239
|
+
href: topic.articles[0]?.url,
|
|
240
|
+
target: "_blank",
|
|
241
|
+
rel: "noreferrer",
|
|
242
|
+
style: styles.topicCard,
|
|
243
|
+
children: [
|
|
244
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.topicName, children: topic.topic }),
|
|
245
|
+
topic.articles[0] && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: styles.headline, children: topic.articles[0].title }),
|
|
246
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.count, children: [
|
|
247
|
+
topic.newsCount,
|
|
248
|
+
" article",
|
|
249
|
+
topic.newsCount === 1 ? "" : "s"
|
|
250
|
+
] })
|
|
251
|
+
]
|
|
252
|
+
},
|
|
253
|
+
topic.topic
|
|
254
|
+
)) })
|
|
255
|
+
] });
|
|
256
|
+
}
|
|
257
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { className, style: { ...styles.root, ...styles.list, ...style }, children: topics.map((topic) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.fullTopic, children: [
|
|
258
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.fullTopicHeader, children: [
|
|
259
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: topic.topic }),
|
|
260
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: styles.count, children: [
|
|
261
|
+
topic.newsCount,
|
|
262
|
+
" article",
|
|
263
|
+
topic.newsCount === 1 ? "" : "s"
|
|
264
|
+
] })
|
|
265
|
+
] }),
|
|
266
|
+
topic.articles.slice(0, 5).map((article, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: styles.article, children: [
|
|
267
|
+
article.url ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("a", { href: article.url, target: "_blank", rel: "noreferrer", style: styles.articleLink, children: article.title }) : article.title,
|
|
268
|
+
article.source && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { opacity: 0.6 }, children: [
|
|
269
|
+
" \xB7 ",
|
|
270
|
+
article.source
|
|
271
|
+
] })
|
|
272
|
+
] }, article.url ?? i))
|
|
273
|
+
] }, topic.topic)) });
|
|
274
|
+
}
|
|
275
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
276
|
+
0 && (module.exports = {
|
|
277
|
+
TrendingNews,
|
|
278
|
+
clearTrendingNewsCache,
|
|
279
|
+
getTrendingNews,
|
|
280
|
+
getTrendingNewsForTopic,
|
|
281
|
+
useTrendingNews
|
|
282
|
+
});
|
|
283
|
+
//# sourceMappingURL=index.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/index.ts","../src/lib/cache.ts","../src/api/trending.ts","../src/hooks/useTrendingNews.ts","../src/components/TrendingNews.tsx"],"sourcesContent":["export * from './types';\nexport { clearTrendingNewsCache } from './lib/cache';\nexport * from './api/trending';\nexport * from './hooks/useTrendingNews';\nexport * from './components/TrendingNews';\n","const CACHE_PREFIX = 'trending-news-cache:';\nconst CACHE_TTL_MS = 10 * 60 * 1000;\n\ntype CacheEntry<T> = {\n timestamp: number;\n data: T;\n};\n\nfunction getStorage(): Storage | null {\n if (typeof window === 'undefined' || !window.localStorage) return null;\n return window.localStorage;\n}\n\n/** Returns the cached value for `key` if it was written within the last 10 minutes. */\nexport function readCachedTrendingNews<T>(key: string): T | null {\n const storage = getStorage();\n if (!storage) return null;\n\n try {\n const raw = storage.getItem(CACHE_PREFIX + key);\n if (!raw) return null;\n\n const entry = JSON.parse(raw) as CacheEntry<T>;\n if (Date.now() - entry.timestamp > CACHE_TTL_MS) {\n storage.removeItem(CACHE_PREFIX + key);\n return null;\n }\n\n return entry.data;\n } catch {\n return null;\n }\n}\n\nexport function writeCachedTrendingNews<T>(key: string, data: T): void {\n const storage = getStorage();\n if (!storage) return;\n\n try {\n const entry: CacheEntry<T> = { timestamp: Date.now(), data };\n storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));\n } catch {\n // Storage full or unavailable (e.g. private browsing) — safe to ignore,\n // the next call will simply hit the network again.\n }\n}\n\n/** Clears all cached trending news responses. Mainly useful for tests/debugging. */\nexport function clearTrendingNewsCache(): void {\n const storage = getStorage();\n if (!storage) return;\n\n for (let i = storage.length - 1; i >= 0; i--) {\n const key = storage.key(i);\n if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);\n }\n}\n","import type { NewsArticle, TrendingNewsData, TrendingNewsOptions, TrendingNewsTopicData } from '../types';\nimport { readCachedTrendingNews, writeCachedTrendingNews } from '../lib/cache';\n\ntype WorkerArticle = {\n title?: string;\n url?: string;\n source?: string;\n published_at?: string;\n};\n\ntype WorkerTopic = {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: WorkerArticle[];\n};\n\ntype WorkerTopicsResponse = {\n date?: string;\n topics: WorkerTopic[];\n error?: string;\n};\n\ntype WorkerTopicResponse = {\n topic: string;\n news_count: number;\n articles: WorkerArticle[];\n error?: string;\n};\n\nfunction mapArticles(articles: WorkerArticle[] = []): NewsArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n publishedAt: a.published_at,\n }));\n}\n\nfunction buildUrl(apiEndpoint: string, options: TrendingNewsOptions) {\n const url = new URL(apiEndpoint);\n if (options.topic) url.searchParams.set('topic', options.topic);\n return url.toString();\n}\n\n/**\n * Fetches trending topics (or, when `options.topic` is set, news for a\n * single topic) from a deployed instance of `worker/index.ts`.\n */\nexport async function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, options);\n\n const cached = readCachedTrendingNews<TrendingNewsData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicsResponse;\n if (data.error) throw new Error(data.error);\n\n const limit = options.limit ?? 25;\n const result: TrendingNewsData = {\n date: data.date,\n topics: (data.topics ?? []).slice(0, limit).map((t) => ({\n topic: t.topic,\n wikiRank: t.wiki_rank,\n wikiViews: t.wiki_views,\n newsCount: t.news_count,\n articles: mapArticles(t.articles),\n })),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n\n/** Fetches news articles for a single topic. */\nexport async function getTrendingNewsForTopic(\n topic: string,\n options: Omit<TrendingNewsOptions, 'topic'>\n): Promise<TrendingNewsTopicData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, { ...options, topic });\n\n const cached = readCachedTrendingNews<TrendingNewsTopicData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicResponse;\n if (data.error) throw new Error(data.error);\n\n const result: TrendingNewsTopicData = {\n topic: data.topic,\n newsCount: data.news_count,\n articles: mapArticles(data.articles),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n","import { useEffect, useState } from 'react';\nimport type { TrendingNewsData, TrendingNewsOptions } from '../types';\nimport { getTrendingNews } from '../api/trending';\n\nexport function useTrendingNews(options: TrendingNewsOptions = {}) {\n const [data, setData] = useState<TrendingNewsData | null>(null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(Boolean(options.apiEndpoint));\n\n useEffect(() => {\n if (!options.apiEndpoint) {\n setLoading(false);\n return;\n }\n\n let active = true;\n setLoading(true);\n setError(null);\n\n getTrendingNews(options)\n .then((result) => { if (active) setData(result); })\n .catch((err) => { if (active) setError(err instanceof Error ? err : new Error('Unknown error')); })\n .finally(() => { if (active) setLoading(false); });\n\n return () => { active = false; };\n }, [options.apiEndpoint, options.topic, options.limit]);\n\n return { data, error, loading };\n}\n","import React from 'react';\nimport { useTrendingNews } from '../hooks/useTrendingNews';\nimport type { TrendingNewsOptions } from '../types';\n\ntype Props = TrendingNewsOptions & {\n className?: string;\n style?: React.CSSProperties;\n compact?: boolean;\n /** Max trending topics to render (default 8). */\n maxTopics?: number;\n};\n\nconst styles = {\n root: {\n fontFamily: 'system-ui, sans-serif',\n border: '1px solid #e5e7eb',\n borderRadius: 12,\n padding: 16,\n background: '#fff',\n color: '#111827',\n maxWidth: 640,\n } as React.CSSProperties,\n compactRoot: {\n fontFamily: 'system-ui, sans-serif',\n display: 'flex',\n flexDirection: 'column',\n gap: 6,\n padding: '10px 14px',\n borderRadius: 8,\n } as React.CSSProperties,\n compactHeader: {\n fontSize: 11,\n fontWeight: 600,\n opacity: 0.75,\n letterSpacing: 0.3,\n textTransform: 'uppercase',\n } as React.CSSProperties,\n topicRow: { display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 2 } as React.CSSProperties,\n topicCard: {\n minWidth: 160,\n maxWidth: 200,\n padding: '8px 10px',\n borderRadius: 8,\n background: 'rgba(255,255,255,0.06)',\n display: 'flex',\n flexDirection: 'column',\n gap: 4,\n textDecoration: 'none',\n color: 'inherit',\n } as React.CSSProperties,\n topicName: {\n fontSize: 12,\n fontWeight: 600,\n whiteSpace: 'nowrap',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n } as React.CSSProperties,\n headline: {\n fontSize: 11,\n opacity: 0.8,\n display: '-webkit-box',\n WebkitLineClamp: 2,\n WebkitBoxOrient: 'vertical',\n overflow: 'hidden',\n } as React.CSSProperties,\n count: { fontSize: 10, opacity: 0.6 } as React.CSSProperties,\n list: { display: 'flex', flexDirection: 'column', gap: 16 } as React.CSSProperties,\n fullTopic: { display: 'flex', flexDirection: 'column', gap: 6 } as React.CSSProperties,\n fullTopicHeader: { display: 'flex', alignItems: 'baseline', gap: 8 } as React.CSSProperties,\n article: { fontSize: 13 } as React.CSSProperties,\n articleLink: { color: 'inherit', textDecoration: 'none' } as React.CSSProperties,\n};\n\n/**\n * Trending news widget. Requires `apiEndpoint` pointing at a deployed\n * instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders\n * nothing when it's not configured, still loading, or errored, so it never\n * disrupts the layout it's dropped into.\n */\nexport function TrendingNews(props: Props) {\n const { className, style, compact, maxTopics = 8, ...options } = props;\n const { data, loading, error } = useTrendingNews(options);\n\n if (!options.apiEndpoint) return null;\n if (loading && !data) return null;\n if (error) return null;\n if (!data || data.topics.length === 0) return null;\n\n const topics = data.topics.slice(0, maxTopics);\n\n if (compact) {\n return (\n <div className={className} style={{ ...styles.compactRoot, ...style }}>\n <div style={styles.compactHeader}>Trending</div>\n <div style={styles.topicRow}>\n {topics.map((topic) => (\n <a\n key={topic.topic}\n href={topic.articles[0]?.url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={styles.topicCard}\n >\n <div style={styles.topicName}>{topic.topic}</div>\n {topic.articles[0] && <div style={styles.headline}>{topic.articles[0].title}</div>}\n <div style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </div>\n </a>\n ))}\n </div>\n </div>\n );\n }\n\n return (\n <div className={className} style={{ ...styles.root, ...styles.list, ...style }}>\n {topics.map((topic) => (\n <div key={topic.topic} style={styles.fullTopic}>\n <div style={styles.fullTopicHeader}>\n <strong>{topic.topic}</strong>\n <span style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </span>\n </div>\n {topic.articles.slice(0, 5).map((article, i) => (\n <div key={article.url ?? i} style={styles.article}>\n {article.url ? (\n <a href={article.url} target=\"_blank\" rel=\"noreferrer\" style={styles.articleLink}>\n {article.title}\n </a>\n ) : (\n article.title\n )}\n {article.source && <span style={{ opacity: 0.6 }}> · {article.source}</span>}\n </div>\n ))}\n </div>\n ))}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type NewsArticle = {
|
|
4
|
+
title: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
source?: string;
|
|
7
|
+
publishedAt?: string;
|
|
8
|
+
};
|
|
9
|
+
type TrendingTopic = {
|
|
10
|
+
topic: string;
|
|
11
|
+
wikiRank?: number;
|
|
12
|
+
wikiViews?: number;
|
|
13
|
+
newsCount: number;
|
|
14
|
+
articles: NewsArticle[];
|
|
15
|
+
};
|
|
16
|
+
type TrendingNewsData = {
|
|
17
|
+
date?: string;
|
|
18
|
+
topics: TrendingTopic[];
|
|
19
|
+
};
|
|
20
|
+
type TrendingNewsTopicData = {
|
|
21
|
+
topic: string;
|
|
22
|
+
newsCount: number;
|
|
23
|
+
articles: NewsArticle[];
|
|
24
|
+
};
|
|
25
|
+
type TrendingNewsOptions = {
|
|
26
|
+
/** URL of a deployed instance of the bundled Cloudflare Worker (`worker/index.ts`). */
|
|
27
|
+
apiEndpoint?: string;
|
|
28
|
+
/** When set, fetches news for this single topic instead of the daily trending list. */
|
|
29
|
+
topic?: string;
|
|
30
|
+
/** Max trending topics to request from the worker (default 25). */
|
|
31
|
+
limit?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Clears all cached trending news responses. Mainly useful for tests/debugging. */
|
|
35
|
+
declare function clearTrendingNewsCache(): void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetches trending topics (or, when `options.topic` is set, news for a
|
|
39
|
+
* single topic) from a deployed instance of `worker/index.ts`.
|
|
40
|
+
*/
|
|
41
|
+
declare function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData>;
|
|
42
|
+
/** Fetches news articles for a single topic. */
|
|
43
|
+
declare function getTrendingNewsForTopic(topic: string, options: Omit<TrendingNewsOptions, 'topic'>): Promise<TrendingNewsTopicData>;
|
|
44
|
+
|
|
45
|
+
declare function useTrendingNews(options?: TrendingNewsOptions): {
|
|
46
|
+
data: TrendingNewsData | null;
|
|
47
|
+
error: Error | null;
|
|
48
|
+
loading: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Props = TrendingNewsOptions & {
|
|
52
|
+
className?: string;
|
|
53
|
+
style?: React.CSSProperties;
|
|
54
|
+
compact?: boolean;
|
|
55
|
+
/** Max trending topics to render (default 8). */
|
|
56
|
+
maxTopics?: number;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Trending news widget. Requires `apiEndpoint` pointing at a deployed
|
|
60
|
+
* instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders
|
|
61
|
+
* nothing when it's not configured, still loading, or errored, so it never
|
|
62
|
+
* disrupts the layout it's dropped into.
|
|
63
|
+
*/
|
|
64
|
+
declare function TrendingNews(props: Props): React.JSX.Element | null;
|
|
65
|
+
|
|
66
|
+
export { type NewsArticle, TrendingNews, type TrendingNewsData, type TrendingNewsOptions, type TrendingNewsTopicData, type TrendingTopic, clearTrendingNewsCache, getTrendingNews, getTrendingNewsForTopic, useTrendingNews };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
type NewsArticle = {
|
|
4
|
+
title: string;
|
|
5
|
+
url?: string;
|
|
6
|
+
source?: string;
|
|
7
|
+
publishedAt?: string;
|
|
8
|
+
};
|
|
9
|
+
type TrendingTopic = {
|
|
10
|
+
topic: string;
|
|
11
|
+
wikiRank?: number;
|
|
12
|
+
wikiViews?: number;
|
|
13
|
+
newsCount: number;
|
|
14
|
+
articles: NewsArticle[];
|
|
15
|
+
};
|
|
16
|
+
type TrendingNewsData = {
|
|
17
|
+
date?: string;
|
|
18
|
+
topics: TrendingTopic[];
|
|
19
|
+
};
|
|
20
|
+
type TrendingNewsTopicData = {
|
|
21
|
+
topic: string;
|
|
22
|
+
newsCount: number;
|
|
23
|
+
articles: NewsArticle[];
|
|
24
|
+
};
|
|
25
|
+
type TrendingNewsOptions = {
|
|
26
|
+
/** URL of a deployed instance of the bundled Cloudflare Worker (`worker/index.ts`). */
|
|
27
|
+
apiEndpoint?: string;
|
|
28
|
+
/** When set, fetches news for this single topic instead of the daily trending list. */
|
|
29
|
+
topic?: string;
|
|
30
|
+
/** Max trending topics to request from the worker (default 25). */
|
|
31
|
+
limit?: number;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Clears all cached trending news responses. Mainly useful for tests/debugging. */
|
|
35
|
+
declare function clearTrendingNewsCache(): void;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fetches trending topics (or, when `options.topic` is set, news for a
|
|
39
|
+
* single topic) from a deployed instance of `worker/index.ts`.
|
|
40
|
+
*/
|
|
41
|
+
declare function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData>;
|
|
42
|
+
/** Fetches news articles for a single topic. */
|
|
43
|
+
declare function getTrendingNewsForTopic(topic: string, options: Omit<TrendingNewsOptions, 'topic'>): Promise<TrendingNewsTopicData>;
|
|
44
|
+
|
|
45
|
+
declare function useTrendingNews(options?: TrendingNewsOptions): {
|
|
46
|
+
data: TrendingNewsData | null;
|
|
47
|
+
error: Error | null;
|
|
48
|
+
loading: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type Props = TrendingNewsOptions & {
|
|
52
|
+
className?: string;
|
|
53
|
+
style?: React.CSSProperties;
|
|
54
|
+
compact?: boolean;
|
|
55
|
+
/** Max trending topics to render (default 8). */
|
|
56
|
+
maxTopics?: number;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* Trending news widget. Requires `apiEndpoint` pointing at a deployed
|
|
60
|
+
* instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders
|
|
61
|
+
* nothing when it's not configured, still loading, or errored, so it never
|
|
62
|
+
* disrupts the layout it's dropped into.
|
|
63
|
+
*/
|
|
64
|
+
declare function TrendingNews(props: Props): React.JSX.Element | null;
|
|
65
|
+
|
|
66
|
+
export { type NewsArticle, TrendingNews, type TrendingNewsData, type TrendingNewsOptions, type TrendingNewsTopicData, type TrendingTopic, clearTrendingNewsCache, getTrendingNews, getTrendingNewsForTopic, useTrendingNews };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// src/lib/cache.ts
|
|
2
|
+
var CACHE_PREFIX = "trending-news-cache:";
|
|
3
|
+
var CACHE_TTL_MS = 10 * 60 * 1e3;
|
|
4
|
+
function getStorage() {
|
|
5
|
+
if (typeof window === "undefined" || !window.localStorage) return null;
|
|
6
|
+
return window.localStorage;
|
|
7
|
+
}
|
|
8
|
+
function readCachedTrendingNews(key) {
|
|
9
|
+
const storage = getStorage();
|
|
10
|
+
if (!storage) return null;
|
|
11
|
+
try {
|
|
12
|
+
const raw = storage.getItem(CACHE_PREFIX + key);
|
|
13
|
+
if (!raw) return null;
|
|
14
|
+
const entry = JSON.parse(raw);
|
|
15
|
+
if (Date.now() - entry.timestamp > CACHE_TTL_MS) {
|
|
16
|
+
storage.removeItem(CACHE_PREFIX + key);
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
return entry.data;
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeCachedTrendingNews(key, data) {
|
|
25
|
+
const storage = getStorage();
|
|
26
|
+
if (!storage) return;
|
|
27
|
+
try {
|
|
28
|
+
const entry = { timestamp: Date.now(), data };
|
|
29
|
+
storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));
|
|
30
|
+
} catch {
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function clearTrendingNewsCache() {
|
|
34
|
+
const storage = getStorage();
|
|
35
|
+
if (!storage) return;
|
|
36
|
+
for (let i = storage.length - 1; i >= 0; i--) {
|
|
37
|
+
const key = storage.key(i);
|
|
38
|
+
if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// src/api/trending.ts
|
|
43
|
+
function mapArticles(articles = []) {
|
|
44
|
+
return articles.map((a) => ({
|
|
45
|
+
title: a.title ?? "",
|
|
46
|
+
url: a.url,
|
|
47
|
+
source: a.source,
|
|
48
|
+
publishedAt: a.published_at
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
function buildUrl(apiEndpoint, options) {
|
|
52
|
+
const url = new URL(apiEndpoint);
|
|
53
|
+
if (options.topic) url.searchParams.set("topic", options.topic);
|
|
54
|
+
return url.toString();
|
|
55
|
+
}
|
|
56
|
+
async function getTrendingNews(options) {
|
|
57
|
+
if (!options.apiEndpoint) {
|
|
58
|
+
throw new Error("trending-news-api: apiEndpoint is required");
|
|
59
|
+
}
|
|
60
|
+
const url = buildUrl(options.apiEndpoint, options);
|
|
61
|
+
const cached = readCachedTrendingNews(url);
|
|
62
|
+
if (cached) return cached;
|
|
63
|
+
const response = await fetch(url, { headers: { "Content-Type": "application/json" } });
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);
|
|
66
|
+
}
|
|
67
|
+
const data = await response.json();
|
|
68
|
+
if (data.error) throw new Error(data.error);
|
|
69
|
+
const limit = options.limit ?? 25;
|
|
70
|
+
const result = {
|
|
71
|
+
date: data.date,
|
|
72
|
+
topics: (data.topics ?? []).slice(0, limit).map((t) => ({
|
|
73
|
+
topic: t.topic,
|
|
74
|
+
wikiRank: t.wiki_rank,
|
|
75
|
+
wikiViews: t.wiki_views,
|
|
76
|
+
newsCount: t.news_count,
|
|
77
|
+
articles: mapArticles(t.articles)
|
|
78
|
+
}))
|
|
79
|
+
};
|
|
80
|
+
writeCachedTrendingNews(url, result);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
async function getTrendingNewsForTopic(topic, options) {
|
|
84
|
+
if (!options.apiEndpoint) {
|
|
85
|
+
throw new Error("trending-news-api: apiEndpoint is required");
|
|
86
|
+
}
|
|
87
|
+
const url = buildUrl(options.apiEndpoint, { ...options, topic });
|
|
88
|
+
const cached = readCachedTrendingNews(url);
|
|
89
|
+
if (cached) return cached;
|
|
90
|
+
const response = await fetch(url, { headers: { "Content-Type": "application/json" } });
|
|
91
|
+
if (!response.ok) {
|
|
92
|
+
throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);
|
|
93
|
+
}
|
|
94
|
+
const data = await response.json();
|
|
95
|
+
if (data.error) throw new Error(data.error);
|
|
96
|
+
const result = {
|
|
97
|
+
topic: data.topic,
|
|
98
|
+
newsCount: data.news_count,
|
|
99
|
+
articles: mapArticles(data.articles)
|
|
100
|
+
};
|
|
101
|
+
writeCachedTrendingNews(url, result);
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/hooks/useTrendingNews.ts
|
|
106
|
+
import { useEffect, useState } from "react";
|
|
107
|
+
function useTrendingNews(options = {}) {
|
|
108
|
+
const [data, setData] = useState(null);
|
|
109
|
+
const [error, setError] = useState(null);
|
|
110
|
+
const [loading, setLoading] = useState(Boolean(options.apiEndpoint));
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
if (!options.apiEndpoint) {
|
|
113
|
+
setLoading(false);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let active = true;
|
|
117
|
+
setLoading(true);
|
|
118
|
+
setError(null);
|
|
119
|
+
getTrendingNews(options).then((result) => {
|
|
120
|
+
if (active) setData(result);
|
|
121
|
+
}).catch((err) => {
|
|
122
|
+
if (active) setError(err instanceof Error ? err : new Error("Unknown error"));
|
|
123
|
+
}).finally(() => {
|
|
124
|
+
if (active) setLoading(false);
|
|
125
|
+
});
|
|
126
|
+
return () => {
|
|
127
|
+
active = false;
|
|
128
|
+
};
|
|
129
|
+
}, [options.apiEndpoint, options.topic, options.limit]);
|
|
130
|
+
return { data, error, loading };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/components/TrendingNews.tsx
|
|
134
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
135
|
+
var styles = {
|
|
136
|
+
root: {
|
|
137
|
+
fontFamily: "system-ui, sans-serif",
|
|
138
|
+
border: "1px solid #e5e7eb",
|
|
139
|
+
borderRadius: 12,
|
|
140
|
+
padding: 16,
|
|
141
|
+
background: "#fff",
|
|
142
|
+
color: "#111827",
|
|
143
|
+
maxWidth: 640
|
|
144
|
+
},
|
|
145
|
+
compactRoot: {
|
|
146
|
+
fontFamily: "system-ui, sans-serif",
|
|
147
|
+
display: "flex",
|
|
148
|
+
flexDirection: "column",
|
|
149
|
+
gap: 6,
|
|
150
|
+
padding: "10px 14px",
|
|
151
|
+
borderRadius: 8
|
|
152
|
+
},
|
|
153
|
+
compactHeader: {
|
|
154
|
+
fontSize: 11,
|
|
155
|
+
fontWeight: 600,
|
|
156
|
+
opacity: 0.75,
|
|
157
|
+
letterSpacing: 0.3,
|
|
158
|
+
textTransform: "uppercase"
|
|
159
|
+
},
|
|
160
|
+
topicRow: { display: "flex", gap: 10, overflowX: "auto", paddingBottom: 2 },
|
|
161
|
+
topicCard: {
|
|
162
|
+
minWidth: 160,
|
|
163
|
+
maxWidth: 200,
|
|
164
|
+
padding: "8px 10px",
|
|
165
|
+
borderRadius: 8,
|
|
166
|
+
background: "rgba(255,255,255,0.06)",
|
|
167
|
+
display: "flex",
|
|
168
|
+
flexDirection: "column",
|
|
169
|
+
gap: 4,
|
|
170
|
+
textDecoration: "none",
|
|
171
|
+
color: "inherit"
|
|
172
|
+
},
|
|
173
|
+
topicName: {
|
|
174
|
+
fontSize: 12,
|
|
175
|
+
fontWeight: 600,
|
|
176
|
+
whiteSpace: "nowrap",
|
|
177
|
+
overflow: "hidden",
|
|
178
|
+
textOverflow: "ellipsis"
|
|
179
|
+
},
|
|
180
|
+
headline: {
|
|
181
|
+
fontSize: 11,
|
|
182
|
+
opacity: 0.8,
|
|
183
|
+
display: "-webkit-box",
|
|
184
|
+
WebkitLineClamp: 2,
|
|
185
|
+
WebkitBoxOrient: "vertical",
|
|
186
|
+
overflow: "hidden"
|
|
187
|
+
},
|
|
188
|
+
count: { fontSize: 10, opacity: 0.6 },
|
|
189
|
+
list: { display: "flex", flexDirection: "column", gap: 16 },
|
|
190
|
+
fullTopic: { display: "flex", flexDirection: "column", gap: 6 },
|
|
191
|
+
fullTopicHeader: { display: "flex", alignItems: "baseline", gap: 8 },
|
|
192
|
+
article: { fontSize: 13 },
|
|
193
|
+
articleLink: { color: "inherit", textDecoration: "none" }
|
|
194
|
+
};
|
|
195
|
+
function TrendingNews(props) {
|
|
196
|
+
const { className, style, compact, maxTopics = 8, ...options } = props;
|
|
197
|
+
const { data, loading, error } = useTrendingNews(options);
|
|
198
|
+
if (!options.apiEndpoint) return null;
|
|
199
|
+
if (loading && !data) return null;
|
|
200
|
+
if (error) return null;
|
|
201
|
+
if (!data || data.topics.length === 0) return null;
|
|
202
|
+
const topics = data.topics.slice(0, maxTopics);
|
|
203
|
+
if (compact) {
|
|
204
|
+
return /* @__PURE__ */ jsxs("div", { className, style: { ...styles.compactRoot, ...style }, children: [
|
|
205
|
+
/* @__PURE__ */ jsx("div", { style: styles.compactHeader, children: "Trending" }),
|
|
206
|
+
/* @__PURE__ */ jsx("div", { style: styles.topicRow, children: topics.map((topic) => /* @__PURE__ */ jsxs(
|
|
207
|
+
"a",
|
|
208
|
+
{
|
|
209
|
+
href: topic.articles[0]?.url,
|
|
210
|
+
target: "_blank",
|
|
211
|
+
rel: "noreferrer",
|
|
212
|
+
style: styles.topicCard,
|
|
213
|
+
children: [
|
|
214
|
+
/* @__PURE__ */ jsx("div", { style: styles.topicName, children: topic.topic }),
|
|
215
|
+
topic.articles[0] && /* @__PURE__ */ jsx("div", { style: styles.headline, children: topic.articles[0].title }),
|
|
216
|
+
/* @__PURE__ */ jsxs("div", { style: styles.count, children: [
|
|
217
|
+
topic.newsCount,
|
|
218
|
+
" article",
|
|
219
|
+
topic.newsCount === 1 ? "" : "s"
|
|
220
|
+
] })
|
|
221
|
+
]
|
|
222
|
+
},
|
|
223
|
+
topic.topic
|
|
224
|
+
)) })
|
|
225
|
+
] });
|
|
226
|
+
}
|
|
227
|
+
return /* @__PURE__ */ jsx("div", { className, style: { ...styles.root, ...styles.list, ...style }, children: topics.map((topic) => /* @__PURE__ */ jsxs("div", { style: styles.fullTopic, children: [
|
|
228
|
+
/* @__PURE__ */ jsxs("div", { style: styles.fullTopicHeader, children: [
|
|
229
|
+
/* @__PURE__ */ jsx("strong", { children: topic.topic }),
|
|
230
|
+
/* @__PURE__ */ jsxs("span", { style: styles.count, children: [
|
|
231
|
+
topic.newsCount,
|
|
232
|
+
" article",
|
|
233
|
+
topic.newsCount === 1 ? "" : "s"
|
|
234
|
+
] })
|
|
235
|
+
] }),
|
|
236
|
+
topic.articles.slice(0, 5).map((article, i) => /* @__PURE__ */ jsxs("div", { style: styles.article, children: [
|
|
237
|
+
article.url ? /* @__PURE__ */ jsx("a", { href: article.url, target: "_blank", rel: "noreferrer", style: styles.articleLink, children: article.title }) : article.title,
|
|
238
|
+
article.source && /* @__PURE__ */ jsxs("span", { style: { opacity: 0.6 }, children: [
|
|
239
|
+
" \xB7 ",
|
|
240
|
+
article.source
|
|
241
|
+
] })
|
|
242
|
+
] }, article.url ?? i))
|
|
243
|
+
] }, topic.topic)) });
|
|
244
|
+
}
|
|
245
|
+
export {
|
|
246
|
+
TrendingNews,
|
|
247
|
+
clearTrendingNewsCache,
|
|
248
|
+
getTrendingNews,
|
|
249
|
+
getTrendingNewsForTopic,
|
|
250
|
+
useTrendingNews
|
|
251
|
+
};
|
|
252
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/lib/cache.ts","../src/api/trending.ts","../src/hooks/useTrendingNews.ts","../src/components/TrendingNews.tsx"],"sourcesContent":["const CACHE_PREFIX = 'trending-news-cache:';\nconst CACHE_TTL_MS = 10 * 60 * 1000;\n\ntype CacheEntry<T> = {\n timestamp: number;\n data: T;\n};\n\nfunction getStorage(): Storage | null {\n if (typeof window === 'undefined' || !window.localStorage) return null;\n return window.localStorage;\n}\n\n/** Returns the cached value for `key` if it was written within the last 10 minutes. */\nexport function readCachedTrendingNews<T>(key: string): T | null {\n const storage = getStorage();\n if (!storage) return null;\n\n try {\n const raw = storage.getItem(CACHE_PREFIX + key);\n if (!raw) return null;\n\n const entry = JSON.parse(raw) as CacheEntry<T>;\n if (Date.now() - entry.timestamp > CACHE_TTL_MS) {\n storage.removeItem(CACHE_PREFIX + key);\n return null;\n }\n\n return entry.data;\n } catch {\n return null;\n }\n}\n\nexport function writeCachedTrendingNews<T>(key: string, data: T): void {\n const storage = getStorage();\n if (!storage) return;\n\n try {\n const entry: CacheEntry<T> = { timestamp: Date.now(), data };\n storage.setItem(CACHE_PREFIX + key, JSON.stringify(entry));\n } catch {\n // Storage full or unavailable (e.g. private browsing) — safe to ignore,\n // the next call will simply hit the network again.\n }\n}\n\n/** Clears all cached trending news responses. Mainly useful for tests/debugging. */\nexport function clearTrendingNewsCache(): void {\n const storage = getStorage();\n if (!storage) return;\n\n for (let i = storage.length - 1; i >= 0; i--) {\n const key = storage.key(i);\n if (key?.startsWith(CACHE_PREFIX)) storage.removeItem(key);\n }\n}\n","import type { NewsArticle, TrendingNewsData, TrendingNewsOptions, TrendingNewsTopicData } from '../types';\nimport { readCachedTrendingNews, writeCachedTrendingNews } from '../lib/cache';\n\ntype WorkerArticle = {\n title?: string;\n url?: string;\n source?: string;\n published_at?: string;\n};\n\ntype WorkerTopic = {\n topic: string;\n wiki_rank?: number;\n wiki_views?: number;\n news_count: number;\n articles: WorkerArticle[];\n};\n\ntype WorkerTopicsResponse = {\n date?: string;\n topics: WorkerTopic[];\n error?: string;\n};\n\ntype WorkerTopicResponse = {\n topic: string;\n news_count: number;\n articles: WorkerArticle[];\n error?: string;\n};\n\nfunction mapArticles(articles: WorkerArticle[] = []): NewsArticle[] {\n return articles.map((a) => ({\n title: a.title ?? '',\n url: a.url,\n source: a.source,\n publishedAt: a.published_at,\n }));\n}\n\nfunction buildUrl(apiEndpoint: string, options: TrendingNewsOptions) {\n const url = new URL(apiEndpoint);\n if (options.topic) url.searchParams.set('topic', options.topic);\n return url.toString();\n}\n\n/**\n * Fetches trending topics (or, when `options.topic` is set, news for a\n * single topic) from a deployed instance of `worker/index.ts`.\n */\nexport async function getTrendingNews(options: TrendingNewsOptions): Promise<TrendingNewsData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, options);\n\n const cached = readCachedTrendingNews<TrendingNewsData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicsResponse;\n if (data.error) throw new Error(data.error);\n\n const limit = options.limit ?? 25;\n const result: TrendingNewsData = {\n date: data.date,\n topics: (data.topics ?? []).slice(0, limit).map((t) => ({\n topic: t.topic,\n wikiRank: t.wiki_rank,\n wikiViews: t.wiki_views,\n newsCount: t.news_count,\n articles: mapArticles(t.articles),\n })),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n\n/** Fetches news articles for a single topic. */\nexport async function getTrendingNewsForTopic(\n topic: string,\n options: Omit<TrendingNewsOptions, 'topic'>\n): Promise<TrendingNewsTopicData> {\n if (!options.apiEndpoint) {\n throw new Error('trending-news-api: apiEndpoint is required');\n }\n\n const url = buildUrl(options.apiEndpoint, { ...options, topic });\n\n const cached = readCachedTrendingNews<TrendingNewsTopicData>(url);\n if (cached) return cached;\n\n const response = await fetch(url, { headers: { 'Content-Type': 'application/json' } });\n if (!response.ok) {\n throw new Error(`Trending news request failed: ${response.status} ${response.statusText}`);\n }\n\n const data = (await response.json()) as WorkerTopicResponse;\n if (data.error) throw new Error(data.error);\n\n const result: TrendingNewsTopicData = {\n topic: data.topic,\n newsCount: data.news_count,\n articles: mapArticles(data.articles),\n };\n\n writeCachedTrendingNews(url, result);\n return result;\n}\n","import { useEffect, useState } from 'react';\nimport type { TrendingNewsData, TrendingNewsOptions } from '../types';\nimport { getTrendingNews } from '../api/trending';\n\nexport function useTrendingNews(options: TrendingNewsOptions = {}) {\n const [data, setData] = useState<TrendingNewsData | null>(null);\n const [error, setError] = useState<Error | null>(null);\n const [loading, setLoading] = useState(Boolean(options.apiEndpoint));\n\n useEffect(() => {\n if (!options.apiEndpoint) {\n setLoading(false);\n return;\n }\n\n let active = true;\n setLoading(true);\n setError(null);\n\n getTrendingNews(options)\n .then((result) => { if (active) setData(result); })\n .catch((err) => { if (active) setError(err instanceof Error ? err : new Error('Unknown error')); })\n .finally(() => { if (active) setLoading(false); });\n\n return () => { active = false; };\n }, [options.apiEndpoint, options.topic, options.limit]);\n\n return { data, error, loading };\n}\n","import React from 'react';\nimport { useTrendingNews } from '../hooks/useTrendingNews';\nimport type { TrendingNewsOptions } from '../types';\n\ntype Props = TrendingNewsOptions & {\n className?: string;\n style?: React.CSSProperties;\n compact?: boolean;\n /** Max trending topics to render (default 8). */\n maxTopics?: number;\n};\n\nconst styles = {\n root: {\n fontFamily: 'system-ui, sans-serif',\n border: '1px solid #e5e7eb',\n borderRadius: 12,\n padding: 16,\n background: '#fff',\n color: '#111827',\n maxWidth: 640,\n } as React.CSSProperties,\n compactRoot: {\n fontFamily: 'system-ui, sans-serif',\n display: 'flex',\n flexDirection: 'column',\n gap: 6,\n padding: '10px 14px',\n borderRadius: 8,\n } as React.CSSProperties,\n compactHeader: {\n fontSize: 11,\n fontWeight: 600,\n opacity: 0.75,\n letterSpacing: 0.3,\n textTransform: 'uppercase',\n } as React.CSSProperties,\n topicRow: { display: 'flex', gap: 10, overflowX: 'auto', paddingBottom: 2 } as React.CSSProperties,\n topicCard: {\n minWidth: 160,\n maxWidth: 200,\n padding: '8px 10px',\n borderRadius: 8,\n background: 'rgba(255,255,255,0.06)',\n display: 'flex',\n flexDirection: 'column',\n gap: 4,\n textDecoration: 'none',\n color: 'inherit',\n } as React.CSSProperties,\n topicName: {\n fontSize: 12,\n fontWeight: 600,\n whiteSpace: 'nowrap',\n overflow: 'hidden',\n textOverflow: 'ellipsis',\n } as React.CSSProperties,\n headline: {\n fontSize: 11,\n opacity: 0.8,\n display: '-webkit-box',\n WebkitLineClamp: 2,\n WebkitBoxOrient: 'vertical',\n overflow: 'hidden',\n } as React.CSSProperties,\n count: { fontSize: 10, opacity: 0.6 } as React.CSSProperties,\n list: { display: 'flex', flexDirection: 'column', gap: 16 } as React.CSSProperties,\n fullTopic: { display: 'flex', flexDirection: 'column', gap: 6 } as React.CSSProperties,\n fullTopicHeader: { display: 'flex', alignItems: 'baseline', gap: 8 } as React.CSSProperties,\n article: { fontSize: 13 } as React.CSSProperties,\n articleLink: { color: 'inherit', textDecoration: 'none' } as React.CSSProperties,\n};\n\n/**\n * Trending news widget. Requires `apiEndpoint` pointing at a deployed\n * instance of the bundled Cloudflare Worker (`worker/index.ts`) — renders\n * nothing when it's not configured, still loading, or errored, so it never\n * disrupts the layout it's dropped into.\n */\nexport function TrendingNews(props: Props) {\n const { className, style, compact, maxTopics = 8, ...options } = props;\n const { data, loading, error } = useTrendingNews(options);\n\n if (!options.apiEndpoint) return null;\n if (loading && !data) return null;\n if (error) return null;\n if (!data || data.topics.length === 0) return null;\n\n const topics = data.topics.slice(0, maxTopics);\n\n if (compact) {\n return (\n <div className={className} style={{ ...styles.compactRoot, ...style }}>\n <div style={styles.compactHeader}>Trending</div>\n <div style={styles.topicRow}>\n {topics.map((topic) => (\n <a\n key={topic.topic}\n href={topic.articles[0]?.url}\n target=\"_blank\"\n rel=\"noreferrer\"\n style={styles.topicCard}\n >\n <div style={styles.topicName}>{topic.topic}</div>\n {topic.articles[0] && <div style={styles.headline}>{topic.articles[0].title}</div>}\n <div style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </div>\n </a>\n ))}\n </div>\n </div>\n );\n }\n\n return (\n <div className={className} style={{ ...styles.root, ...styles.list, ...style }}>\n {topics.map((topic) => (\n <div key={topic.topic} style={styles.fullTopic}>\n <div style={styles.fullTopicHeader}>\n <strong>{topic.topic}</strong>\n <span style={styles.count}>\n {topic.newsCount} article{topic.newsCount === 1 ? '' : 's'}\n </span>\n </div>\n {topic.articles.slice(0, 5).map((article, i) => (\n <div key={article.url ?? i} style={styles.article}>\n {article.url ? (\n <a href={article.url} target=\"_blank\" rel=\"noreferrer\" style={styles.articleLink}>\n {article.title}\n </a>\n ) : (\n article.title\n )}\n {article.source && <span style={{ opacity: 0.6 }}> · {article.source}</span>}\n </div>\n ))}\n </div>\n ))}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "trending-news-api",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React trending news widget backed by Wikipedia pageviews and The News API, via a Cloudflare Worker proxy",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"react",
|
|
7
|
+
"news",
|
|
8
|
+
"trending",
|
|
9
|
+
"wikipedia",
|
|
10
|
+
"thenewsapi",
|
|
11
|
+
"cloudflare",
|
|
12
|
+
"component-library"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/OpenSourceAGI/qwksearch-research-agent.git",
|
|
17
|
+
"directory": "packages/trending-news-api"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"module": "./dist/index.js",
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"exports": {
|
|
24
|
+
".": {
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"import": "./dist/index.js",
|
|
27
|
+
"require": "./dist/index.cjs"
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"README.md"
|
|
33
|
+
],
|
|
34
|
+
"sideEffects": false,
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"react": "^18 || ^19",
|
|
37
|
+
"react-dom": "^18 || ^19"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@cloudflare/workers-types": "^4.20250115.0",
|
|
41
|
+
"@testing-library/react": "^16.1.0",
|
|
42
|
+
"@types/react": "^19.0.0",
|
|
43
|
+
"@types/react-dom": "^19.0.0",
|
|
44
|
+
"@vitejs/plugin-react": "^6.0.3",
|
|
45
|
+
"@vitest/coverage-v8": "^4.0.18",
|
|
46
|
+
"jsdom": "^28.1.0",
|
|
47
|
+
"react": "^19.2.7",
|
|
48
|
+
"react-dom": "^19.2.7",
|
|
49
|
+
"tsup": "^8.2.4",
|
|
50
|
+
"typescript": "^5.6.3",
|
|
51
|
+
"vitest": "^4.0.18",
|
|
52
|
+
"wrangler": "^3.88.0"
|
|
53
|
+
},
|
|
54
|
+
"scripts": {
|
|
55
|
+
"build": "tsup",
|
|
56
|
+
"typecheck": "tsc --noEmit",
|
|
57
|
+
"test": "vitest run",
|
|
58
|
+
"test:coverage": "vitest run --coverage",
|
|
59
|
+
"worker:typecheck": "tsc --noEmit -p worker/tsconfig.json",
|
|
60
|
+
"worker:dev": "wrangler dev --config worker/wrangler.jsonc",
|
|
61
|
+
"worker:deploy": "wrangler deploy --config worker/wrangler.jsonc"
|
|
62
|
+
}
|
|
63
|
+
}
|