feedkeeper 0.27.2 → 0.28.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "feedkeeper",
3
- "version": "0.27.2",
3
+ "version": "0.28.1",
4
4
  "description": "A lightweight, self-hosted RSS reader and remote MCP server for your news, blogs, and AI workflows.",
5
5
  "keywords": [
6
6
  "rss",
@@ -27,6 +27,7 @@ const FEATURES = [
27
27
  "content.prepare",
28
28
  "item.lead",
29
29
  "item.author",
30
+ "item.empty-gallery",
30
31
  "images",
31
32
  "muted-keywords",
32
33
  "opml",
@@ -0,0 +1,10 @@
1
+ -- Classification is shared publisher metadata, not a deletion or a per-user read state.
2
+ ALTER TABLE items ADD COLUMN is_empty_gallery INTEGER NOT NULL DEFAULT 0 CHECK (is_empty_gallery IN (0, 1));
3
+ -- Reuse the bounded metadata backfill; existing content and reading history stay intact.
4
+ UPDATE items SET lead_version = NULL;
5
+
6
+ CREATE TRIGGER trg_gallery_revision AFTER UPDATE OF is_empty_gallery ON items
7
+ WHEN OLD.is_empty_gallery IS NOT NEW.is_empty_gallery
8
+ BEGIN
9
+ UPDATE items SET content_revision = content_revision + 1 WHERE id = NEW.id;
10
+ END;
@@ -36,7 +36,7 @@ export function readerHtml(html, title, author) {
36
36
  export function contentMetadata(item) {
37
37
  const source = item.full_content_html ? "reader" : item.content_html ? "feed" : "snippet";
38
38
  return { revision: item.content_revision, source, status: item.extraction_status,
39
- attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at };
39
+ attemptedAt: item.extraction_attempted_at, retryAt: item.extraction_retry_at, isEmptyGallery: Boolean(item.is_empty_gallery) };
40
40
  }
41
41
  export function sourceText(item) {
42
42
  return `${decodeEntities(item.title) ?? ""}\n${derivedText(item.full_content_html || item.content_html, item.title) || cleanSnippet(decodeEntities(item.content_snippet), item.title) || ""}`.trim();
@@ -121,8 +121,14 @@ function removeReaderNoise(root, title, stats, author) {
121
121
  if (ancestor.querySelector("a[href]") && PRICE.test(text))
122
122
  card = ancestor;
123
123
  }
124
- if (card)
124
+ if (card) {
125
+ const parent = card.parentElement;
125
126
  drop(card, "product-card");
127
+ // A long product name can put the enclosing header just beyond the card's size limit.
128
+ if (parent && PRODUCT_LABEL.test(flat(parent.textContent)) && !parent.querySelector("img, picture, iframe, video, a[href]")) {
129
+ drop(parent, "empty-product-label");
130
+ }
131
+ }
126
132
  }
127
133
  // Structured data is for machines. Frameworks render it as an element of any name (`<component type="application/ld+json">`),
128
134
  // and a stray block may hold nothing else: neither is text for a reader.
@@ -248,6 +254,27 @@ function removeReaderNoise(root, title, stats, author) {
248
254
  drop(block, "comment-rules");
249
255
  break;
250
256
  }
257
+ // Empty nested slots are structural evidence: the next story paragraph is outside this ad container.
258
+ // Do not infer an empty ad from a standalone heading followed by text (which may itself be sponsored).
259
+ for (const container of [...root.querySelectorAll("div, aside, section, p")]) {
260
+ if (!root.contains(container))
261
+ continue;
262
+ const text = flat(container.textContent);
263
+ const product = PRODUCT_LABEL.test(text);
264
+ if (!product && !AD_LABEL.test(text))
265
+ continue;
266
+ if (container.querySelector("img, picture, iframe, video, audio, a[href], object, embed"))
267
+ continue;
268
+ const emptySlot = container.querySelector("div:empty, ins:empty");
269
+ // Readability may remove the empty slot, leaving only its enclosing block and label paragraph.
270
+ const isolated = container.matches("div, aside, section") && container.querySelector("p") &&
271
+ (product || AD_SLOT_LABEL.test(text));
272
+ const next = container.nextElementSibling;
273
+ const nextHasMedia = next && (next.matches("img, picture, iframe, video, audio, a[href]") ||
274
+ next.querySelector("img, picture, iframe, video, audio, a[href]"));
275
+ if (emptySlot || (isolated && !nextHasMedia))
276
+ drop(container, product ? "empty-product-label" : "empty-ad-label");
277
+ }
251
278
  // An ad label whose ad is not there (a script fills it): sponsored content itself stays, the label above nothing goes.
252
279
  const labelled = leafBlocks(root);
253
280
  labelled.forEach((block, index) => {
@@ -362,13 +389,14 @@ export function cleanReaderHtml(html, title, stats = {}, author) {
362
389
  return html;
363
390
  const root = JSDOM.fragment(html);
364
391
  const images = resolveCustomImages(root, stats);
392
+ const duplicates = collapseAdjacentImages(root, stats);
365
393
  const variants = collapseImageVariants(root, stats);
366
394
  const videos = resolveVideoPlayers(root, stats);
367
395
  const noise = removeReaderNoise(root, title, stats, author);
368
396
  const typography = removeTypography(root, stats);
369
397
  videos.links.forEach(link => { if (root.contains(link))
370
398
  link.setAttribute("data-media", "video"); });
371
- return images || variants || videos.changed || noise || typography ? serialize(root) : html;
399
+ return images || duplicates || variants || videos.changed || noise || typography ? serialize(root) : html;
372
400
  }
373
401
  const PLAY_LABEL = /^(video starten|video abspielen|abspielen|wiedergabe( starten)?|play( video)?|watch( video)?|start video)$/i;
374
402
  const PLAY_SOURCE = /(?:^|[/_.-])play[-_]?(?:icon|button|btn)?\.(?:svg|png|webp|gif)(?:[?#]|$)/i;
@@ -539,6 +567,46 @@ function collapseImageVariants(root, stats) {
539
567
  }
540
568
  return changed;
541
569
  }
570
+ /** Responsive copies may be separate images whose CSS normally hides one. Only exact adjacent copies go;
571
+ * text, other media, different links, captions and responsive sources keep images distinct. */
572
+ function collapseAdjacentImages(root, stats) {
573
+ const walker = root.ownerDocument.createTreeWalker(root, 1 | 4);
574
+ let previous = null;
575
+ const duplicates = [];
576
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
577
+ if (node.nodeType === 3) {
578
+ if (flat(node.textContent))
579
+ previous = null;
580
+ continue;
581
+ }
582
+ const element = node;
583
+ if (element.matches("video, audio, iframe, object, embed, hr"))
584
+ previous = null;
585
+ if (element.tagName !== "IMG")
586
+ continue;
587
+ const src = flat(element.getAttribute("data-src") || element.getAttribute("data-lazy-src") ||
588
+ element.getAttribute("data-original") || element.getAttribute("src"));
589
+ if (!src || /^(data|blob):/i.test(src)) {
590
+ previous = null;
591
+ continue;
592
+ }
593
+ const picture = element.closest("picture");
594
+ const key = JSON.stringify([src, element.getAttribute("srcset"), element.getAttribute("data-srcset"),
595
+ element.getAttribute("alt"), element.getAttribute("title"), element.closest("a")?.getAttribute("href"),
596
+ [...(picture?.querySelectorAll("source") ?? [])].map(source => [source.getAttribute("srcset"), source.getAttribute("media"), source.getAttribute("type")])]);
597
+ if (previous === key)
598
+ duplicates.push(picture?.querySelectorAll("img").length === 1 ? picture : element);
599
+ else
600
+ previous = key;
601
+ }
602
+ for (const duplicate of duplicates) {
603
+ const parent = duplicate.parentNode;
604
+ duplicate.remove();
605
+ pruneEmpty(parent);
606
+ stats["duplicate-image"] = (stats["duplicate-image"] ?? 0) + 1;
607
+ }
608
+ return duplicates.length > 0;
609
+ }
542
610
  const CREDIT_PATTERNS = [
543
611
  /^(©|\(c\)\s|copyright\b)/i,
544
612
  /^[([]?(Bild|Foto|Fotos|Bildquelle|Bildrechte|Bildnachweis|Titelbild|Beitragsbild|Photo|Image|Credits?|Grafik|Illustration)[)\]]?\s*[:/|]/i,
@@ -589,7 +657,9 @@ const COMMENT_PATTERNS = [
589
657
  /\b(let us know|tell us) .{0,40}in the comments\b/i,
590
658
  /\bNur angemeldete Benutzer können kommentieren\b/i,
591
659
  ];
592
- const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted)\s*:?$/i;
660
+ const AD_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Advertorial|Ads?|Sponsored( content| post)?|Paid (content|post)|Gesponsert|Promoted|広告|広告掲載|スポンサー(?:記事|コンテンツ)?|宣伝|PR)\s*[::]?$/i;
661
+ const AD_SLOT_LABEL = /^(Anzeige|Werbung|Advertisement|Advertising|Ads?|広告|広告掲載|宣伝)\s*[::]?$/i;
662
+ const PRODUCT_LABEL = /^(Produkthinweis|Produktempfehlung|Product (notice|recommendation)|商品紹介|商品案内|製品紹介)\s*[::]?$/i;
593
663
  const COMMENT_RULES_HEADING = /^(Regeln für Kommentare|Kommentarregeln|Kommentar-Richtlinien|Community[- ]Richtlinien|(Comment(ing)?|Community|Discussion|House) (rules|guidelines|policy|policies)|Rules for comments|Commenting rules)\s*:?$/i;
594
664
  const TAG_LABEL = /^(Schlagw(ö|oe)rter|Schlagworte|Tags?|Keywords|Themen|Topics)\s*:?$/i;
595
665
  const VIDEO_CARD = /^\d{1,2}:\d{2}(:\d{2})?(?=\D)\s*\S/;
@@ -1,4 +1,5 @@
1
1
  import { isCachedConsentSnippet } from "./extractor.js";
2
+ import { refreshLead } from "./lead.js";
2
3
  import { db } from "../db/index.js";
3
4
  import { hasUserCapability } from "../auth/capabilities.js";
4
5
  import { loadFullText, executeFullText } from "./fullText.js";
@@ -344,6 +345,7 @@ export function startFullTextScheduler() {
344
345
  for (const item of cached)
345
346
  if (isCachedConsentSnippet(item.full_content_html)) {
346
347
  db.prepare("UPDATE items SET full_content_html=NULL,extraction_status='pending',extraction_retry_at=NULL WHERE id=?").run(item.id);
348
+ refreshLead(item.id);
347
349
  }
348
350
  })();
349
351
  tick();
@@ -0,0 +1,33 @@
1
+ import { JSDOM } from "jsdom";
2
+ const GALLERY_COMPONENTS = new Set([
3
+ "bilder", "bildergalerie", "bildergalerien", "fotogalerie", "fotogalerien", "fotostrecke", "fotostrecken",
4
+ "galerie", "galerien", "gallery", "galleries", "photogallery", "photogalleries", "slideshow", "slideshows",
5
+ "photos", "pictures", "images",
6
+ ]);
7
+ const PLACEHOLDERS = new Set([
8
+ "", "mehr", "weiterlesen", "mehr lesen", "read more", "more", "continue reading",
9
+ "zur galerie", "zur bildergalerie", "view gallery", "続きを読む", "もっと読む",
10
+ ]);
11
+ function isPlaceholder(source) {
12
+ const fragment = JSDOM.fragment(source ?? "");
13
+ fragment.querySelectorAll("script, style, noscript").forEach(node => node.remove());
14
+ fragment.querySelectorAll("p, div, br, li, h1, h2, h3, blockquote, figcaption").forEach(node => node.append(" "));
15
+ const normalized = (fragment.textContent ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
16
+ return PLACEHOLDERS.has(normalized);
17
+ }
18
+ /** A gallery path alone is not enough: even one editorial sentence or caption keeps the article visible. */
19
+ export function isEmptyGallery(item) {
20
+ if (!item.link)
21
+ return false;
22
+ let path;
23
+ try {
24
+ path = decodeURIComponent(new URL(item.link).pathname).toLowerCase();
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ if (!path.split(/[^\p{L}\p{N}]+/u).some(component => GALLERY_COMPONENTS.has(component)))
30
+ return false;
31
+ return isPlaceholder(item.content_snippet) && isPlaceholder(item.full_content_html || item.content_html);
32
+ }
33
+ //# sourceMappingURL=galleryContent.js.map
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { db } from "../db/index.js";
3
3
  import { LEAD_VERSION, leadText } from "./contentNoise.js";
4
+ import { isEmptyGallery } from "./galleryContent.js";
4
5
  const BACKFILL_BATCH = 50;
5
6
  const BACKFILL_PAUSE_MS = 100;
6
7
  const COMPUTED_LIMIT = 300;
@@ -10,10 +11,17 @@ export function computeLead(item) {
10
11
  }
11
12
  /** Derive and store the lead of one item; call after its title, summary or content was written. */
12
13
  export function refreshLead(itemId) {
13
- const item = db.prepare("SELECT title, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
14
+ const item = db.prepare("SELECT title, link, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
14
15
  if (!item)
15
16
  return;
16
- db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?").run(computeLead(item), LEAD_VERSION, itemId);
17
+ db.prepare("UPDATE items SET content_lead = ?, lead_version = ?, is_empty_gallery = ? WHERE id = ?")
18
+ .run(computeLead(item), LEAD_VERSION, Number(isEmptyGallery(item)), itemId);
19
+ }
20
+ /** A changed feed summary/link can affect visibility while the lead still belongs to the retained full text. */
21
+ export function refreshGalleryClassification(itemId) {
22
+ const item = db.prepare("SELECT link, content_snippet, content_html, full_content_html FROM items WHERE id = ?").get(itemId);
23
+ if (item)
24
+ db.prepare("UPDATE items SET is_empty_gallery = ? WHERE id = ?").run(Number(isEmptyGallery(item)), itemId);
17
25
  }
18
26
  /**
19
27
  * The lead to serve: the stored one while its rules are current, otherwise derived on the spot, so an article is
@@ -35,10 +43,10 @@ export function leadOf(item) {
35
43
  }
36
44
  /** Derive leads for items that have none or were derived by older rules; returns how many were handled. */
37
45
  export function backfillLeads(limit = BACKFILL_BATCH) {
38
- const rows = db.prepare("SELECT id, title, content_snippet, content_html, full_content_html FROM items WHERE lead_version IS NULL OR lead_version <> ? LIMIT ?").all(LEAD_VERSION, limit);
39
- const update = db.prepare("UPDATE items SET content_lead = ?, lead_version = ? WHERE id = ?");
46
+ const rows = db.prepare("SELECT id, title, link, content_snippet, content_html, full_content_html FROM items WHERE lead_version IS NULL OR lead_version <> ? LIMIT ?").all(LEAD_VERSION, limit);
47
+ const update = db.prepare("UPDATE items SET content_lead = ?, lead_version = ?, is_empty_gallery = ? WHERE id = ?");
40
48
  db.transaction(() => { for (const row of rows)
41
- update.run(computeLead(row), LEAD_VERSION, row.id); })();
49
+ update.run(computeLead(row), LEAD_VERSION, Number(isEmptyGallery(row)), row.id); })();
42
50
  return rows.length;
43
51
  }
44
52
  /** Works through the backlog in small steps so polling and requests are never held up for long. */
@@ -2,7 +2,7 @@ import { db } from "../db/index.js";
2
2
  import { articleText } from "./articleContent.js";
3
3
  import { LEAD_VERSION } from "./contentNoise.js";
4
4
  import { largerHeroImage } from "./heroImage.js";
5
- import { refreshLead } from "./lead.js";
5
+ import { refreshGalleryClassification, refreshLead } from "./lead.js";
6
6
  import { decodeEntities } from "./text.js";
7
7
  export function findFeedByUrl(url) {
8
8
  return db.prepare("SELECT * FROM feeds WHERE url = ?").get(url);
@@ -191,10 +191,13 @@ export function upsertItems(feedId, items) {
191
191
  if (changed) {
192
192
  update.run(row.title ?? null, row.link ?? null, nextSnippet, retained, row.publishedAt ?? null, nextImage, row.author ?? null, feedId, row.guid);
193
193
  const leadChanged = (retained !== null && retained !== previous.content_html) ||
194
+ (row.link !== undefined && row.link !== previous.link) ||
194
195
  (row.title !== undefined && row.title !== previous.title) ||
195
196
  (!keptPrevious && row.contentSnippet !== undefined && row.contentSnippet !== previous.content_snippet);
196
197
  if (previous.lead_version !== LEAD_VERSION || (leadChanged && !previous.full_content_html))
197
198
  refreshLead(previous.id);
199
+ else if (leadChanged)
200
+ refreshGalleryClassification(previous.id);
198
201
  }
199
202
  else if (previous.lead_version !== LEAD_VERSION && !previous.full_content_html) {
200
203
  refreshLead(previous.id);
@@ -216,6 +219,10 @@ export function listItemsForUser(userId, opts = {}) {
216
219
  conditions.push("(s.id IS NOT NULL OR b.item_id IS NOT NULL)");
217
220
  }
218
221
  const params = [];
222
+ // Keep the stored item available to direct reads and sync; deliberate saves/notes override stream filtering.
223
+ conditions.push(`(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR
224
+ EXISTS (SELECT 1 FROM item_notes gallery_note WHERE gallery_note.item_id = i.id AND gallery_note.user_id = ?))`);
225
+ params.push(userId);
219
226
  if (opts.feedId) {
220
227
  conditions.push("i.feed_id = ?");
221
228
  params.push(opts.feedId);
@@ -564,6 +571,7 @@ export function repairEncodedText() {
564
571
  const snippet = decodeEntities(row.content_snippet);
565
572
  if (title !== row.title || snippet !== row.content_snippet) {
566
573
  update.run(title ?? null, snippet ?? null, row.id);
574
+ refreshLead(row.id);
567
575
  fixed++;
568
576
  }
569
577
  }
@@ -686,7 +694,8 @@ export function needsSubstringSearch(raw) {
686
694
  }
687
695
  export function searchItemsForUser(userId, opts) {
688
696
  const fts = needsSubstringSearch(opts.query) ? "" : formatFtsQuery(opts.query);
689
- const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"];
697
+ const conditions = ["(s.id IS NOT NULL OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)",
698
+ "(i.is_empty_gallery = 0 OR b.item_id IS NOT NULL OR n.user_id IS NOT NULL)"];
690
699
  const params = [];
691
700
  if (opts.feedId) {
692
701
  conditions.push("i.feed_id = ?");
@@ -108,11 +108,11 @@ export function publishCuratedEdition(userId, input, now = new Date()) {
108
108
  for (const id of data.itemIds) {
109
109
  if (!canAccessItem(userId, id))
110
110
  throw new NativeError("item_not_found", 404);
111
- const item = db.prepare(`SELECT s.folder_id, s.id IS NOT NULL AS subscribed,
111
+ const item = db.prepare(`SELECT s.folder_id, s.id IS NOT NULL AS subscribed, i.is_empty_gallery,
112
112
  EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
113
113
  (instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0)) AS muted
114
114
  FROM items i LEFT JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ? WHERE i.id = ?`).get(userId, userId, id);
115
- if (item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
115
+ if (item.is_empty_gallery || item.muted || (item.subscribed && (item.folder_id === null ? !prefs.showUnfiled : prefs.hiddenFolderIds.includes(item.folder_id)))) {
116
116
  throw new NativeError("item_excluded");
117
117
  }
118
118
  }
@@ -224,7 +224,8 @@ export function editionCandidates(userId, now = new Date(), limit = 200) {
224
224
  row_number() OVER (PARTITION BY i.feed_id ORDER BY coalesce(julianday(i.published_at), julianday(i.created_at)) DESC, i.id DESC) AS source_rank
225
225
  FROM items i JOIN subscriptions s ON s.feed_id = i.feed_id AND s.user_id = ?
226
226
  JOIN feeds f ON f.id = i.feed_id LEFT JOIN folders ON folders.id = s.folder_id
227
- WHERE NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)
227
+ WHERE i.is_empty_gallery = 0
228
+ AND NOT EXISTS (SELECT 1 FROM item_reads r WHERE r.item_id = i.id AND r.user_id = ?)
228
229
  AND NOT EXISTS (SELECT 1 FROM user_muted_keywords m WHERE m.user_id = ? AND
229
230
  (instr(lower(coalesce(i.title, '')), lower(m.keyword)) > 0 OR instr(lower(coalesce(i.content_snippet, '')), lower(m.keyword)) > 0))
230
231
  AND coalesce(julianday(i.published_at), julianday(i.created_at)) BETWEEN julianday(?) AND julianday(?)
@@ -112,6 +112,8 @@ export function newItemsPushPayload(feed, userId, label, latest) {
112
112
  const muted = db.prepare("SELECT keyword FROM user_muted_keywords WHERE user_id = ?")
113
113
  .all(userId).map(row => row.keyword.toLowerCase());
114
114
  const fresh = latest.filter(item => {
115
+ if (item.is_empty_gallery)
116
+ return false;
115
117
  const text = `${item.title ?? ""} ${item.content_snippet ?? ""}`.toLowerCase();
116
118
  return !muted.some(keyword => text.includes(keyword));
117
119
  });
@@ -111,7 +111,7 @@ export class PushOutbox {
111
111
  if (!feed)
112
112
  return; // Feed deletion cascades the event.
113
113
  const latest = db.prepare(`
114
- SELECT id,title,content_snippet FROM items WHERE feed_id = ? AND id <= ? ORDER BY id DESC LIMIT ?`)
114
+ SELECT id,title,content_snippet,is_empty_gallery FROM items WHERE feed_id = ? AND id <= ? ORDER BY id DESC LIMIT ?`)
115
115
  .all(event.feed_id, event.max_item_id, event.item_count);
116
116
  const payload = newItemsPushPayload(feed, subscription.user_id, subscription.label, latest);
117
117
  if (!payload) {
@@ -57,6 +57,7 @@ function loadItemContents(userId, itemIds) {
57
57
  const rows = db
58
58
  .prepare(`SELECT i.id,
59
59
  i.content_revision,
60
+ i.is_empty_gallery,
60
61
  CASE WHEN i.full_content_html IS NOT NULL AND i.full_content_html != '' THEN 'reader'
61
62
  WHEN i.content_html IS NOT NULL AND i.content_html != '' THEN 'feed'
62
63
  ELSE 'snippet' END AS source,
@@ -75,6 +76,7 @@ function loadItemContents(userId, itemIds) {
75
76
  status: row.extraction_status,
76
77
  attemptedAt: row.extraction_attempted_at,
77
78
  retryAt: row.extraction_retry_at,
79
+ isEmptyGallery: Boolean(row.is_empty_gallery),
78
80
  });
79
81
  }
80
82
  return result;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feedkeeper/server",
3
- "version": "0.27.2",
3
+ "version": "0.28.1",
4
4
  "private": true,
5
5
  "type": "module"
6
6
  }