@solutiondesk/changelog 0.0.0 → 1.0.0-9fc05ac

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/dist/react.js ADDED
@@ -0,0 +1,501 @@
1
+ import { useMemo, useState, useRef, useCallback, useEffect, useId } from 'react';
2
+ import { jsx, jsxs, Fragment } from 'react/jsx-runtime';
3
+
4
+ var __defProp = Object.defineProperty;
5
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
6
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
7
+
8
+ // src/seen.ts
9
+ function browserStorage() {
10
+ try {
11
+ const storage = globalThis.localStorage;
12
+ return storage ?? null;
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+ function createSeenTracker(workspace, storage = browserStorage()) {
18
+ const key = `sd-changelog-seen:${workspace.toLowerCase()}`;
19
+ const lastSeen = () => {
20
+ try {
21
+ return storage?.getItem(key) ?? null;
22
+ } catch {
23
+ return null;
24
+ }
25
+ };
26
+ return {
27
+ lastSeen,
28
+ markSeen(publishedAt) {
29
+ try {
30
+ storage?.setItem(key, publishedAt);
31
+ } catch {
32
+ }
33
+ },
34
+ unseen(entries) {
35
+ const seen = lastSeen();
36
+ if (!seen) return 0;
37
+ return entries.filter((entry) => entry.publishedAt > seen).length;
38
+ }
39
+ };
40
+ }
41
+ var CATEGORY_LABELS = {
42
+ en: { new: "New", improved: "Improved", fixed: "Fixed", announcement: "Announcement" },
43
+ de: { new: "Neu", improved: "Verbessert", fixed: "Behoben", announcement: "Ank\xFCndigung" }
44
+ };
45
+ function categoryLabel(category, locale = "en") {
46
+ return CATEGORY_LABELS[locale.toLowerCase().startsWith("de") ? "de" : "en"][category];
47
+ }
48
+
49
+ // ../sdk-core/src/http.ts
50
+ var DEFAULT_BASE_URL = "https://api.solutiondesk.io";
51
+ var SolutionDeskError = class extends Error {
52
+ constructor(message, init) {
53
+ super(message);
54
+ /** The HTTP status; `0` when no answer arrived (network error, aborted). */
55
+ __publicField(this, "status");
56
+ /** The parsed JSON body of the error answer (usually `{ error: string }`), else its text. */
57
+ __publicField(this, "body");
58
+ /** The requested URL. */
59
+ __publicField(this, "url");
60
+ /** Messages per input field on a `400` (e.g. `{ email: "Enter a valid email address." }`). */
61
+ __publicField(this, "fields");
62
+ this.name = "SolutionDeskError";
63
+ this.status = init.status;
64
+ this.body = init.body ?? null;
65
+ this.url = init.url;
66
+ this.fields = fieldsOf(init.body);
67
+ if (init.cause !== void 0) {
68
+ Object.defineProperty(this, "cause", { value: init.cause, enumerable: false });
69
+ }
70
+ }
71
+ /** `404`: unknown workspace, module turned off or unknown item — on purpose indistinguishable. */
72
+ get isNotFound() {
73
+ return this.status === 404;
74
+ }
75
+ /** `429`: over the rate limit; try again later. */
76
+ get isRateLimited() {
77
+ return this.status === 429;
78
+ }
79
+ };
80
+ function fieldsOf(body) {
81
+ if (!body || typeof body !== "object") return {};
82
+ const fields = body.fields;
83
+ if (!fields || typeof fields !== "object") return {};
84
+ const out = {};
85
+ for (const [key, value] of Object.entries(fields)) {
86
+ if (typeof value === "string") out[key] = value;
87
+ }
88
+ return out;
89
+ }
90
+ function messageOf(body, status) {
91
+ if (body && typeof body === "object") {
92
+ const { error, message, statusCode } = body;
93
+ if (typeof statusCode === "number" && typeof message === "string" && message) return message;
94
+ if (typeof error === "string" && error) return error;
95
+ if (typeof message === "string" && message) return message;
96
+ if (Array.isArray(message) && typeof message[0] === "string") return message[0];
97
+ }
98
+ if (typeof body === "string" && body.trim() && body.length <= 300) return body.trim();
99
+ return `SolutionDesk answered ${status}.`;
100
+ }
101
+ function createMemoryEtagCache(maxEntries = 500) {
102
+ const entries = /* @__PURE__ */ new Map();
103
+ return {
104
+ get: (key) => entries.get(key),
105
+ set(key, value) {
106
+ entries.delete(key);
107
+ entries.set(key, value);
108
+ if (entries.size > maxEntries) {
109
+ const oldest = entries.keys().next().value;
110
+ if (oldest !== void 0) entries.delete(oldest);
111
+ }
112
+ }
113
+ };
114
+ }
115
+ function queryString(query) {
116
+ if (!query) return "";
117
+ const parts = [];
118
+ for (const [key, value] of Object.entries(query)) {
119
+ if (value === void 0 || value === null || value === "") continue;
120
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`);
121
+ }
122
+ return parts.length ? `?${parts.join("&")}` : "";
123
+ }
124
+ var seg = (value) => encodeURIComponent(String(value));
125
+ function parseBody(text) {
126
+ if (!text) return null;
127
+ try {
128
+ return JSON.parse(text);
129
+ } catch {
130
+ return text;
131
+ }
132
+ }
133
+ function createHttp(options = {}) {
134
+ const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "").replace(/\/v1$/, "");
135
+ const fetchImpl = options.fetch ?? globalThis.fetch;
136
+ const cache = options.etagCache === true ? createMemoryEtagCache() : options.etagCache ? options.etagCache : void 0;
137
+ const url = (path, query) => `${baseUrl}/v1${path}${queryString(query)}`;
138
+ async function requestWithMeta(req) {
139
+ if (!fetchImpl) {
140
+ throw new Error("No global fetch available: pass `fetch` to the client.");
141
+ }
142
+ const target = url(req.path, req.query);
143
+ const headers = {
144
+ accept: req.accept ?? "application/json",
145
+ ...options.headers,
146
+ ...req.options?.headers
147
+ };
148
+ if (req.body !== void 0) headers["content-type"] = "application/json";
149
+ if (req.apiKey) headers.authorization = `Bearer ${req.apiKey}`;
150
+ const cacheKey = req.method === "GET" && !req.apiKey ? target : void 0;
151
+ const cached = cacheKey && cache ? cache.get(cacheKey) : void 0;
152
+ if (cached && !Object.keys(headers).some((h) => h.toLowerCase() === "if-none-match")) {
153
+ headers["if-none-match"] = cached.etag;
154
+ }
155
+ const init = {
156
+ ...req.options?.fetchOptions,
157
+ method: req.method,
158
+ headers
159
+ };
160
+ if (req.body !== void 0) init.body = JSON.stringify(req.body);
161
+ if (req.options?.signal !== void 0) init.signal = req.options.signal;
162
+ let response;
163
+ try {
164
+ response = await fetchImpl(target, init);
165
+ } catch (cause) {
166
+ const reason = cause instanceof Error ? cause.message : String(cause);
167
+ throw new SolutionDeskError(`The request to SolutionDesk failed: ${reason}`, {
168
+ status: 0,
169
+ url: target,
170
+ cause
171
+ });
172
+ }
173
+ const finalUrl = response.url;
174
+ const meta = {
175
+ status: response.status,
176
+ finalUrl: typeof finalUrl === "string" && finalUrl ? finalUrl : null
177
+ };
178
+ if (response.status === 304 && cached) {
179
+ return { data: cached.body, ...meta };
180
+ }
181
+ const text = await response.text();
182
+ const body = response.ok && req.responseType === "text" ? text : parseBody(text);
183
+ if (!response.ok) {
184
+ throw new SolutionDeskError(messageOf(body, response.status), {
185
+ status: response.status,
186
+ body,
187
+ url: target
188
+ });
189
+ }
190
+ const etag = response.headers.get("etag");
191
+ if (cacheKey && cache && etag) cache.set(cacheKey, { etag, body });
192
+ return { data: body, ...meta };
193
+ }
194
+ return {
195
+ baseUrl,
196
+ url,
197
+ requestWithMeta,
198
+ request: async (req) => (await requestWithMeta(req)).data
199
+ };
200
+ }
201
+ function required(value, name) {
202
+ if (typeof value !== "string" || !value.trim()) {
203
+ throw new TypeError(`SolutionDesk: \`${name}\` is required.`);
204
+ }
205
+ return value;
206
+ }
207
+
208
+ // src/client.ts
209
+ function createChangelogClient(options) {
210
+ const workspace = required(options.workspace, "workspace");
211
+ const http = createHttp(options);
212
+ const base = `/changelog/${seg(workspace)}`;
213
+ const list = (params = {}, requestOptions) => http.request({
214
+ method: "GET",
215
+ path: base,
216
+ query: { category: params.category, limit: params.limit, cursor: params.cursor },
217
+ options: requestOptions
218
+ });
219
+ async function* allEntries(params = {}, requestOptions) {
220
+ let cursor = null;
221
+ do {
222
+ const page = await list(
223
+ { ...params, limit: params.limit ?? 50, cursor },
224
+ requestOptions
225
+ );
226
+ yield* page.entries;
227
+ cursor = page.nextCursor;
228
+ } while (cursor);
229
+ }
230
+ return {
231
+ workspace,
232
+ list,
233
+ allEntries,
234
+ getEntry: (slug, requestOptions) => http.request({
235
+ method: "GET",
236
+ path: `${base}/entries/${seg(required(slug, "slug"))}`,
237
+ options: requestOptions
238
+ })
239
+ };
240
+ }
241
+
242
+ // src/react/use-changelog.ts
243
+ function useChangelog(options) {
244
+ const { workspace, client: given, limit = 10, category, trackSeen = true, initialData } = options;
245
+ const { baseUrl, fetch: fetchImpl, headers, etagCache } = options;
246
+ const headersKey = JSON.stringify(headers ?? {});
247
+ const client = useMemo(
248
+ () => given ?? createChangelogClient({
249
+ workspace,
250
+ baseUrl,
251
+ fetch: fetchImpl,
252
+ headers: JSON.parse(headersKey),
253
+ etagCache
254
+ }),
255
+ [given, workspace, baseUrl, fetchImpl, headersKey, etagCache]
256
+ );
257
+ const tracker = useMemo(
258
+ () => trackSeen ? createSeenTracker(workspace, options.storage) : null,
259
+ [trackSeen, workspace, options.storage]
260
+ );
261
+ const [data, setData] = useState(initialData ?? null);
262
+ const [entries, setEntries] = useState(initialData?.entries ?? []);
263
+ const [status, setStatus] = useState(
264
+ initialData ? "ready" : "loading"
265
+ );
266
+ const [error, setError] = useState(null);
267
+ const [unseen, setUnseen] = useState(0);
268
+ const [loadingMore, setLoadingMore] = useState(false);
269
+ const generation = useRef(0);
270
+ const moreInFlight = useRef(false);
271
+ const settle = useCallback(
272
+ (page) => {
273
+ if (!tracker) return;
274
+ const newest = page.entries[0]?.publishedAt;
275
+ const seen = tracker.lastSeen();
276
+ if (!seen && newest) tracker.markSeen(newest);
277
+ setUnseen(tracker.unseen(page.entries));
278
+ },
279
+ [tracker]
280
+ );
281
+ const reload = useCallback(async () => {
282
+ const run = ++generation.current;
283
+ try {
284
+ const page = await client.list({ limit, category });
285
+ if (run !== generation.current) return;
286
+ setData(page);
287
+ setEntries(page.entries);
288
+ setError(null);
289
+ setStatus("ready");
290
+ settle(page);
291
+ } catch (caught) {
292
+ if (run !== generation.current) return;
293
+ setError(caught);
294
+ setStatus("error");
295
+ }
296
+ }, [client, limit, category, settle]);
297
+ useEffect(() => {
298
+ void reload();
299
+ return () => {
300
+ generation.current++;
301
+ };
302
+ }, [reload]);
303
+ const loadMore = useCallback(async () => {
304
+ if (!data?.nextCursor || moreInFlight.current) return;
305
+ const run = generation.current;
306
+ moreInFlight.current = true;
307
+ setLoadingMore(true);
308
+ try {
309
+ const page = await client.list({ limit, category, cursor: data.nextCursor });
310
+ if (run !== generation.current) return;
311
+ setData(page);
312
+ setEntries((current) => [...current, ...page.entries]);
313
+ setError(null);
314
+ } catch (caught) {
315
+ if (run === generation.current) setError(caught);
316
+ } finally {
317
+ moreInFlight.current = false;
318
+ setLoadingMore(false);
319
+ }
320
+ }, [client, limit, category, data]);
321
+ const markAllSeen = useCallback(() => {
322
+ const newest = entries[0]?.publishedAt;
323
+ if (tracker && newest) tracker.markSeen(newest);
324
+ setUnseen(0);
325
+ }, [tracker, entries]);
326
+ return {
327
+ status,
328
+ entries,
329
+ data,
330
+ error,
331
+ unseen,
332
+ markAllSeen,
333
+ hasMore: !!data?.nextCursor,
334
+ loadingMore,
335
+ loadMore,
336
+ reload
337
+ };
338
+ }
339
+ function formatChangelogDate(iso, locale = "en") {
340
+ return new Intl.DateTimeFormat(locale, { day: "numeric", month: "long", year: "numeric" }).format(
341
+ new Date(iso)
342
+ );
343
+ }
344
+ function ChangelogEntryBody({ html, className }) {
345
+ if (!html) return null;
346
+ return /* @__PURE__ */ jsx(
347
+ "div",
348
+ {
349
+ "data-sd-changelog-body": "",
350
+ className,
351
+ dangerouslySetInnerHTML: { __html: html }
352
+ }
353
+ );
354
+ }
355
+ function ChangelogEntryView({
356
+ entry,
357
+ locale = "en",
358
+ showBody = true,
359
+ classNames = {},
360
+ titleAs: Title = "h3"
361
+ }) {
362
+ return /* @__PURE__ */ jsxs("article", { "data-sd-changelog-entry": "", "data-category": entry.category, className: classNames.entry, children: [
363
+ /* @__PURE__ */ jsxs("div", { "data-sd-changelog-meta": "", className: classNames.meta, children: [
364
+ /* @__PURE__ */ jsx("time", { dateTime: entry.publishedAt, className: classNames.date, children: formatChangelogDate(entry.publishedAt, locale) }),
365
+ /* @__PURE__ */ jsx("span", { "data-sd-changelog-category": entry.category, className: classNames.category, children: categoryLabel(entry.category, locale) })
366
+ ] }),
367
+ /* @__PURE__ */ jsx(Title, { className: classNames.title, children: /* @__PURE__ */ jsx("a", { href: entry.url, children: entry.title }) }),
368
+ entry.summary ? /* @__PURE__ */ jsx("p", { className: classNames.summary, children: entry.summary }) : null,
369
+ showBody ? /* @__PURE__ */ jsx(ChangelogEntryBody, { html: entry.html, className: classNames.body }) : null
370
+ ] });
371
+ }
372
+ function ChangelogFeed(props) {
373
+ const {
374
+ locale = "en",
375
+ showBody = true,
376
+ className,
377
+ entryClassNames,
378
+ renderEntry,
379
+ loading = null,
380
+ empty = null,
381
+ fallback = null,
382
+ moreLabel,
383
+ moreClassName,
384
+ ...options
385
+ } = props;
386
+ const changelog = useChangelog({ trackSeen: false, ...options });
387
+ const de = locale.toLowerCase().startsWith("de");
388
+ if (changelog.status === "loading") return /* @__PURE__ */ jsx(Fragment, { children: loading });
389
+ if (changelog.status === "error") return /* @__PURE__ */ jsx(Fragment, { children: fallback });
390
+ if (changelog.entries.length === 0) return /* @__PURE__ */ jsx(Fragment, { children: empty });
391
+ return /* @__PURE__ */ jsxs("div", { "data-sd-changelog": "", className, children: [
392
+ /* @__PURE__ */ jsx("ol", { "data-sd-changelog-list": "", children: changelog.entries.map((entry) => /* @__PURE__ */ jsx("li", { id: entry.slug, children: renderEntry ? renderEntry(entry) : /* @__PURE__ */ jsx(
393
+ ChangelogEntryView,
394
+ {
395
+ entry,
396
+ locale,
397
+ showBody,
398
+ classNames: entryClassNames
399
+ }
400
+ ) }, entry.slug)) }),
401
+ changelog.hasMore ? /* @__PURE__ */ jsx(
402
+ "button",
403
+ {
404
+ type: "button",
405
+ "data-sd-changelog-more": "",
406
+ className: moreClassName,
407
+ disabled: changelog.loadingMore,
408
+ "aria-busy": changelog.loadingMore || void 0,
409
+ onClick: () => void changelog.loadMore(),
410
+ children: moreLabel ?? (de ? "\xC4ltere Neuigkeiten" : "Older updates")
411
+ }
412
+ ) : null
413
+ ] });
414
+ }
415
+ function WhatsNew(props) {
416
+ const {
417
+ locale = "en",
418
+ label,
419
+ title,
420
+ classNames = {},
421
+ entryClassNames,
422
+ renderBadge,
423
+ ...options
424
+ } = props;
425
+ const changelog = useChangelog(options);
426
+ const [open, setOpen] = useState(false);
427
+ const root = useRef(null);
428
+ const panelId = useId();
429
+ const de = locale.toLowerCase().startsWith("de");
430
+ const fallback = de ? "Was ist neu?" : "What's new";
431
+ useEffect(() => {
432
+ if (!open) return;
433
+ const onKey = (event) => event.key === "Escape" && setOpen(false);
434
+ const onClick = (event) => {
435
+ if (root.current && !root.current.contains(event.target)) setOpen(false);
436
+ };
437
+ document.addEventListener("keydown", onKey);
438
+ document.addEventListener("mousedown", onClick);
439
+ return () => {
440
+ document.removeEventListener("keydown", onKey);
441
+ document.removeEventListener("mousedown", onClick);
442
+ };
443
+ }, [open]);
444
+ if (changelog.status !== "ready" || !changelog.data) return null;
445
+ const { data } = changelog;
446
+ return /* @__PURE__ */ jsxs(
447
+ "div",
448
+ {
449
+ ref: root,
450
+ "data-sd-whats-new": "",
451
+ "data-open": open ? "" : void 0,
452
+ className: classNames.root,
453
+ style: { position: "relative" },
454
+ children: [
455
+ /* @__PURE__ */ jsxs(
456
+ "button",
457
+ {
458
+ type: "button",
459
+ "aria-expanded": open,
460
+ "aria-controls": panelId,
461
+ className: classNames.button,
462
+ onClick: () => {
463
+ if (!open) changelog.markAllSeen();
464
+ setOpen(!open);
465
+ },
466
+ children: [
467
+ label ?? (data.widget.label || fallback),
468
+ changelog.unseen > 0 ? renderBadge ? renderBadge(changelog.unseen) : /* @__PURE__ */ jsx("span", { "data-sd-whats-new-badge": "", className: classNames.badge, children: changelog.unseen }) : null
469
+ ]
470
+ }
471
+ ),
472
+ open ? /* @__PURE__ */ jsxs(
473
+ "div",
474
+ {
475
+ id: panelId,
476
+ role: "dialog",
477
+ "aria-label": typeof title === "string" ? title : data.title || fallback,
478
+ "data-sd-whats-new-panel": "",
479
+ className: classNames.panel,
480
+ style: classNames.panel ? void 0 : { position: "absolute", zIndex: 50 },
481
+ children: [
482
+ /* @__PURE__ */ jsx("div", { "data-sd-whats-new-header": "", className: classNames.header, children: title ?? (data.title || fallback) }),
483
+ /* @__PURE__ */ jsx("ol", { "data-sd-whats-new-list": "", className: classNames.list, children: changelog.entries.map((entry) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
484
+ ChangelogEntryView,
485
+ {
486
+ entry,
487
+ locale,
488
+ showBody: false,
489
+ classNames: entryClassNames
490
+ }
491
+ ) }, entry.slug)) }),
492
+ /* @__PURE__ */ jsx("a", { href: data.pageUrl, "data-sd-whats-new-all": "", className: classNames.footer, children: de ? "Alle Neuigkeiten" : "All updates" })
493
+ ]
494
+ }
495
+ ) : null
496
+ ]
497
+ }
498
+ );
499
+ }
500
+
501
+ export { ChangelogEntryBody, ChangelogEntryView, ChangelogFeed, WhatsNew, formatChangelogDate, useChangelog };
package/package.json CHANGED
@@ -1,10 +1,78 @@
1
1
  {
2
2
  "name": "@solutiondesk/changelog",
3
- "version": "0.0.0",
4
- "description": "OIDC trusted publishing setup package for @solutiondesk/changelog",
3
+ "version": "1.0.0-9fc05ac",
4
+ "description": "Typed, zero-dependency client for the SolutionDesk changelog, plus React hooks and unstyled components for a \"What's new\" feed.",
5
5
  "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
10
- }
6
+ "solutiondesk",
7
+ "changelog",
8
+ "release-notes",
9
+ "whats-new",
10
+ "react",
11
+ "headless",
12
+ "sdk",
13
+ "typescript"
14
+ ],
15
+ "homepage": "https://help.solutiondesk.io/docs/team/changelog",
16
+ "bugs": {
17
+ "url": "https://github.com/wemogy/solutiondesk/issues"
18
+ },
19
+ "license": "MIT",
20
+ "author": "wemogy IT",
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/wemogy/solutiondesk.git",
24
+ "directory": "packages/sdk-changelog"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "type": "module",
30
+ "sideEffects": false,
31
+ "main": "./dist/index.cjs",
32
+ "module": "./dist/index.js",
33
+ "types": "./dist/index.d.ts",
34
+ "exports": {
35
+ ".": {
36
+ "import": {
37
+ "types": "./dist/index.d.ts",
38
+ "default": "./dist/index.js"
39
+ },
40
+ "require": {
41
+ "types": "./dist/index.d.cts",
42
+ "default": "./dist/index.cjs"
43
+ }
44
+ },
45
+ "./react": {
46
+ "import": {
47
+ "types": "./dist/react.d.ts",
48
+ "default": "./dist/react.js"
49
+ },
50
+ "require": {
51
+ "types": "./dist/react.d.cts",
52
+ "default": "./dist/react.cjs"
53
+ }
54
+ },
55
+ "./package.json": "./package.json"
56
+ },
57
+ "files": [
58
+ "dist",
59
+ "README.md",
60
+ "LICENSE"
61
+ ],
62
+ "engines": {
63
+ "node": ">=18"
64
+ },
65
+ "peerDependencies": {
66
+ "react": ">=18"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "react": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "scripts": {
74
+ "build": "rm -rf dist && tsup",
75
+ "typecheck": "tsc --noEmit",
76
+ "test": "vitest run"
77
+ }
78
+ }