@redeemid/emdash 0.0.6 → 0.0.7

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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Shopify customer accounts, member levels and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites. Shopify runs the store and the customer account; EmDash runs the content.
4
4
 
5
- **Status: 0.0.6, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout are covered by an end-to-end test against a fake store.
5
+ **Status: 0.0.7, early.** Runs on Node and Cloudflare Workers. Sign-in, the member mirror, webhooks, levels, rules, the meter and drip, teasers, badges, the page gate, the Shopify cart and checkout are covered by an end-to-end test against a fake store.
6
6
 
7
7
  ## What it does
8
8
 
@@ -13,6 +13,7 @@ Shopify customer accounts, member levels and paywalls for [EmDash](https://githu
13
13
  - **Meter and drip.** Signed-in readers may open a few locked entries free each month or week. A rule can also drip: members read its entries once they've held their level for so many days.
14
14
  - **Teasers and badges.** Locked entries can show their first paragraphs, up to a word count. `<AccessBadge>` names the level an entry needs and shows a lock to readers it keeps out.
15
15
  - **Plans, cart and checkout.** `<Plan>` shows a Shopify product's price and adds it to the visitor's Shopify cart; `<Cart>` shows the count and opens Shopify checkout.
16
+ - **Member account card.** `<MemberCard>` shows members their levels with when each started, the levels they held before, the meter, and Refresh membership, which reads their tags from the store again.
16
17
  - **Account.** Members get an Account link to their Shopify customer account (orders, profile, addresses) and Sign out, which also ends the Shopify session.
17
18
  - **Paywall break block.** Editors write one body and drop a "Paywall break" block where members-only content starts, picking which levels may read on, or leaving it to the entry's rule. `<Content>` renders the body and leaves the rest off the page for everyone else.
18
19
  - **Members only block.** For a members part in the middle of a body: "Members only" (pick the levels) and "Members only: end". Everyone else sees the wall in its place and reads on after it.
@@ -154,13 +155,15 @@ import Cart from "@redeemid/emdash/components/Cart.astro";
154
155
  <Cart />
155
156
  ```
156
157
 
157
- Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that adds it to the visitor's Shopify cart and opens checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag:
158
+ For an account page, `<MemberCard />` shows the member's levels (with when each started), levels they held before, how much of the meter they've used, and links to their store account, Refresh membership and Sign out. Visitors get a sign-in link. Refresh membership (`refreshUrl(returnTo)` in `@redeemid/emdash/access`) sends the member to the store with no sign-in form; a member still signed in there comes straight back with their tags read again and the `refreshed` notice, and anyone else goes through the usual sign-in. Use it after a purchase, when Shopify's tag webhook hasn't arrived yet.
159
+
160
+ Sell a plan. `<Plan>` shows a Shopify product's title and price with a button that adds it to the visitor's Shopify cart and opens checkout (signed-in members check out with their email). Shopify Flow or an app tags the customer after the order, and access follows that tag. With `levels`, members who already hold one of them see "You're on this plan." (`heldLabel`) in place of the button; the Plan block has the same choice:
158
161
 
159
162
  ```astro
160
163
  ---
161
164
  import Plan from "@redeemid/emdash/components/Plan.astro";
162
165
  ---
163
- <Plan handle="gold-membership" label="Join Gold">
166
+ <Plan handle="gold-membership" label="Join Gold" levels={["gold"]}>
164
167
  <p>Shown if the product can't be loaded.</p>
165
168
  </Plan>
166
169
  ```
@@ -53,4 +53,4 @@ The Workers free plan is enough: RedeemID is a native plugin and doesn't need sa
53
53
  3. Click **Sign in**, sign in with that customer's email, and you come back to the post, unlocked.
54
54
  4. Add or remove the tag in Shopify; the post opens or locks again once the webhook arrives.
55
55
 
56
- If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails.
56
+ If sign-in sends you back with `?redeemid_notice=…`, the code says why: `not_configured` (secrets or `shop` missing), `sign_in_unavailable` (store discovery failed), `sign_in_expired`, `staff_account`, `site_not_ready`, `email_required`, `token_failed`, `bad_id_token`, `identity_failed`. A Buy button can return `plan_unavailable` (no such product, or sold out) or `checkout_failed`. The Cart link returns `cart_empty` when there's nothing in the cart, and Account returns `account_unavailable` when store discovery fails. Refresh membership returns `refreshed` once it has read the member's tags again.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/access.ts CHANGED
@@ -187,6 +187,61 @@ export function memberSince(member: MemberRecord, levels: LevelConfig[] = []): s
187
187
  return starts[0] ?? member.linkedAt ?? null;
188
188
  }
189
189
 
190
+ export interface HeldLevel {
191
+ slug: string;
192
+ name: string;
193
+ /** When the level started (see memberSince), ISO, or null when unknown. */
194
+ since: string | null;
195
+ }
196
+
197
+ export interface PastLevel {
198
+ slug: string;
199
+ name: string;
200
+ from: string | null;
201
+ to: string;
202
+ }
203
+
204
+ /**
205
+ * The member's levels now (with when each started) and the levels they held
206
+ * before (from the tags Shopify has since removed), newest first.
207
+ */
208
+ export function membership(member: MemberRecord, levels: LevelConfig[] = []): { current: HeldLevel[]; past: PastLevel[] } {
209
+ const held = levelsFor(member.tags, levels);
210
+ const current = held.map((slug) => {
211
+ const level = levels.find((l) => l.slug === slug)!;
212
+ return { slug, name: level.name, since: memberSince({ ...member, linkedAt: null }, [level]) };
213
+ });
214
+ const pastTags = member.pastTags ?? [];
215
+ const past: PastLevel[] = [];
216
+ for (const level of levels) {
217
+ if (held.includes(level.slug)) continue;
218
+ const wanted = new Set(level.tags.map((t) => t.toLowerCase()));
219
+ const spans = pastTags.filter((p) => wanted.has(p.tag));
220
+ if (spans.length === 0) continue;
221
+ const froms = spans.map((p) => p.from).filter((d): d is string => Boolean(d)).sort();
222
+ const to = spans.map((p) => p.to).sort().at(-1)!;
223
+ past.push({ slug: level.slug, name: level.name, from: froms[0] ?? null, to });
224
+ }
225
+ past.sort((a, b) => (a.to < b.to ? 1 : a.to > b.to ? -1 : 0));
226
+ return { current, past };
227
+ }
228
+
229
+ /** How much of this period's meter the signed-in reader has used, or null when no meter runs. */
230
+ export async function meterUsage(astro: AstroLike): Promise<{ used: number; limit: number; period: "month" | "week" } | null> {
231
+ const locals = astro.locals as Locals;
232
+ const meter = siteConfig(locals.emdash?.config)?.meter;
233
+ const user = locals.user;
234
+ const db = locals.emdash?.db;
235
+ if (!meter || !(meter.limit > 0) || !user?.id || !db) return null;
236
+ const period = meter.period ?? "month";
237
+ try {
238
+ const record = await stores(db).meter.get(`${user.id}:${periodKey(new Date(), period)}`);
239
+ return { used: Math.min(record?.items.length ?? 0, meter.limit), limit: meter.limit, period };
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
190
245
  /** When drip opens an entry for this member: `since` plus `days`. */
191
246
  export function dripOpensAt(since: string | null, days: number): Date | null {
192
247
  if (!since || days <= 0) return null;
@@ -355,6 +410,11 @@ export function accountUrl(returnTo = "/"): string {
355
410
  return `/_emdash/api/auth/redeemid/account?return=${encodeURIComponent(safeReturn(returnTo))}`;
356
411
  }
357
412
 
413
+ /** Re-read the member's tags from the store without a sign-in form when their store session is still open. */
414
+ export function refreshUrl(returnTo = "/"): string {
415
+ return `/_emdash/api/auth/redeemid/login?refresh=1&return=${encodeURIComponent(safeReturn(returnTo))}`;
416
+ }
417
+
358
418
  export function signOutUrl(returnTo = "/"): string {
359
419
  return `/_emdash/api/auth/redeemid/logout?return=${encodeURIComponent(safeReturn(returnTo))}`;
360
420
  }
@@ -3,11 +3,11 @@
3
3
  import Plan from "../components/Plan.astro";
4
4
 
5
5
  interface Props {
6
- node: { handle?: string; title?: string; label?: string };
6
+ node: { handle?: string; title?: string; label?: string; levels?: string[] };
7
7
  }
8
8
 
9
9
  const { node } = Astro.props;
10
10
  const handle = (node.handle ?? "").trim();
11
11
  ---
12
12
 
13
- {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} />}
13
+ {handle && <Plan handle={handle} title={node.title?.trim() || undefined} label={node.label?.trim() || "Join"} levels={Array.isArray(node.levels) ? node.levels : []} />}
@@ -0,0 +1,95 @@
1
+ ---
2
+ /**
3
+ * The member's own account card, for an account page: their levels with
4
+ * when each started, levels they held before, the meter, and links to the
5
+ * store account, Refresh membership and Sign out. Visitors get a sign-in link.
6
+ *
7
+ * <MemberCard />
8
+ *
9
+ * Everything shown comes from the mirrored Shopify customer; Refresh
10
+ * membership reads it from the store again.
11
+ */
12
+ import { accountUrl, membership, meterUsage, refreshUrl, signInUrl, signOutUrl, viewer } from "../access.js";
13
+ import { siteConfig } from "../site-config.js";
14
+
15
+ interface Props {
16
+ /** Heading text; the member's first name is added after it. */
17
+ title?: string;
18
+ }
19
+
20
+ const { title = "Your membership" } = Astro.props;
21
+ const v = await viewer(Astro);
22
+ const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
23
+ const here = Astro.url.pathname + Astro.url.search;
24
+ const m = v.member;
25
+ const disabled = m?.status === "disabled";
26
+ const { current, past } = m && !disabled ? membership(m, config?.levels) : { current: [], past: [] };
27
+ const meter = v.signedIn ? await meterUsage(Astro) : null;
28
+ const date = (iso: string | null) => {
29
+ if (!iso) return null;
30
+ const t = Date.parse(iso);
31
+ return Number.isNaN(t) ? null : new Intl.DateTimeFormat("en-US", { dateStyle: "long", timeZone: "UTC" }).format(new Date(t));
32
+ };
33
+ const name = [m?.firstName, m?.lastName].filter(Boolean).join(" ");
34
+ const join = config?.join;
35
+ ---
36
+
37
+ <section class="redeemid-member" data-redeemid-signed-in={v.signedIn ? "true" : "false"}>
38
+ {v.signedIn ? (
39
+ <>
40
+ <h2 class="redeemid-member__title">{title}</h2>
41
+ {(name || m?.email) && (
42
+ <p class="redeemid-member__who">
43
+ {name && <strong>{name}</strong>}
44
+ {m?.email && <span>{m.email}</span>}
45
+ </p>
46
+ )}
47
+ {disabled ? (
48
+ <p class="redeemid-member__disabled">Your account is turned off. Contact the store for help.</p>
49
+ ) : current.length > 0 ? (
50
+ <ul class="redeemid-member__levels">
51
+ {current.map((l) => (
52
+ <li data-redeemid-level={l.slug}>
53
+ <strong>{l.name}</strong>
54
+ {date(l.since) && <> since <time datetime={l.since ?? undefined}>{date(l.since)}</time></>}
55
+ </li>
56
+ ))}
57
+ </ul>
58
+ ) : (
59
+ <p class="redeemid-member__none">
60
+ You don't have a membership yet.{join && <> <a href={join.href}>{join.label ?? "Become a member"}</a></>}
61
+ </p>
62
+ )}
63
+ {past.length > 0 && (
64
+ <>
65
+ <h3 class="redeemid-member__subtitle">Before</h3>
66
+ <ul class="redeemid-member__past">
67
+ {past.map((l) => (
68
+ <li data-redeemid-level={l.slug}>
69
+ {l.name}
70
+ {date(l.from) ? `, ${date(l.from)} to ${date(l.to)}` : `, until ${date(l.to)}`}
71
+ </li>
72
+ ))}
73
+ </ul>
74
+ </>
75
+ )}
76
+ {meter && current.length === 0 && !disabled && (
77
+ <p class="redeemid-member__meter" data-redeemid-used={meter.used}>
78
+ Free reads {meter.period === "week" ? "this week" : "this month"}: {meter.used} of {meter.limit} used.
79
+ </p>
80
+ )}
81
+ <p class="redeemid-member__actions">
82
+ <a href={accountUrl(here)}>Orders and details</a>
83
+ <a href={refreshUrl(here)}>Refresh membership</a>
84
+ </p>
85
+ <form method="post" action={signOutUrl("/")}>
86
+ <button type="submit">Sign out</button>
87
+ </form>
88
+ <p class="redeemid-member__note">Just bought a plan? It can take a minute to show here. Refresh membership checks again.</p>
89
+ </>
90
+ ) : (
91
+ <p>
92
+ <a href={signInUrl(here)}>{config?.label ?? "Sign in"}</a> to see your membership.
93
+ </p>
94
+ )}
95
+ </section>
@@ -21,6 +21,7 @@ const DEFAULTS: Record<string, string> = {
21
21
  plan_unavailable: "That plan isn't available right now.",
22
22
  checkout_failed: "Checkout didn't open. Try again.",
23
23
  cart_empty: "Your cart is empty.",
24
+ refreshed: "Your membership is up to date.",
24
25
  account_unavailable: "Your account page isn't available right now. Try again in a few minutes.",
25
26
  };
26
27
 
@@ -4,8 +4,12 @@
4
4
  * checkout. Prices come from the Storefront API; the default slot shows if
5
5
  * the product can't be loaded.
6
6
  *
7
- * <Plan handle="gold-membership" />
7
+ * <Plan handle="gold-membership" levels={["gold"]} />
8
+ *
9
+ * With `levels`, members who hold one of them see "You're on this plan"
10
+ * in place of the button.
8
11
  */
12
+ import { viewer } from "../access.js";
9
13
  import { siteConfig } from "../site-config.js";
10
14
  import { plan as loadPlan, type Plan } from "../storefront.js";
11
15
 
@@ -15,9 +19,12 @@ interface Props {
15
19
  label?: string;
16
20
  /** Heading override; defaults to the Shopify product title. */
17
21
  title?: string;
22
+ /** The levels this plan gives. Members holding one see `heldLabel`, not the button. */
23
+ levels?: string[];
24
+ heldLabel?: string;
18
25
  }
19
26
 
20
- const { handle, label = "Join", title } = Astro.props;
27
+ const { handle, label = "Join", title, levels = [], heldLabel = "You're on this plan." } = Astro.props;
21
28
  const config = siteConfig((Astro.locals as { emdash?: { config?: unknown } }).emdash?.config);
22
29
  let p: Plan | null = null;
23
30
  if (config) {
@@ -31,14 +38,17 @@ const price = p?.price
31
38
  ? new Intl.NumberFormat("en-US", { style: "currency", currency: p.price.currencyCode }).format(Number(p.price.amount))
32
39
  : null;
33
40
  const here = Astro.url.pathname + Astro.url.search;
41
+ const held = levels.length > 0 && (await viewer(Astro)).levels.some((l) => levels.includes(l));
34
42
  ---
35
43
 
36
44
  {p ? (
37
- <div class="redeemid-plan" data-redeemid-plan={p.handle}>
45
+ <div class:list={["redeemid-plan", { "is-held": held }]} data-redeemid-plan={p.handle}>
38
46
  <h2 class="redeemid-plan__title">{title ?? p.title}</h2>
39
47
  {price && <p class="redeemid-plan__price">{price}</p>}
40
48
  {p.description && <p class="redeemid-plan__description">{p.description}</p>}
41
- {p.availableForSale ? (
49
+ {held ? (
50
+ <p class="redeemid-plan__held">{heldLabel}</p>
51
+ ) : p.availableForSale ? (
42
52
  <form method="post" action="/_emdash/api/auth/redeemid/buy">
43
53
  <input type="hidden" name="plan" value={p.handle} />
44
54
  <input type="hidden" name="return" value={here} />
package/src/plugin.ts CHANGED
@@ -89,6 +89,12 @@ function blocks(config: RedeemIDConfig) {
89
89
  { type: "text_input" as const, action_id: "handle", label: "Shopify product handle", placeholder: "gold-membership" },
90
90
  { type: "text_input" as const, action_id: "title", label: "Title (optional, Shopify's title otherwise)" },
91
91
  { type: "text_input" as const, action_id: "label", label: "Button label", placeholder: "Join" },
92
+ {
93
+ type: "checkbox" as const,
94
+ action_id: "levels",
95
+ label: "Levels this plan gives (members on them see \"You're on this plan\")",
96
+ options: (config.levels ?? []).map((l) => ({ label: l.name, value: l.slug })),
97
+ },
92
98
  ],
93
99
  },
94
100
  ];
@@ -33,7 +33,11 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
33
33
  const clientId = env(ENV.clientId);
34
34
  if (!config || !storeDomain(config) || !clientId || !emdash?.db || !session) return fail(redirect, returnTo, "not_configured");
35
35
 
36
- if (url.searchParams.get("error")) return fail(redirect, returnTo, "sign_in_cancelled");
36
+ if (url.searchParams.get("error")) {
37
+ // Refresh found no open store session: sign in as usual.
38
+ if (flow?.refresh) return redirect(`/_emdash/api/auth/redeemid/login?return=${encodeURIComponent(returnTo)}`);
39
+ return fail(redirect, returnTo, "sign_in_cancelled");
40
+ }
37
41
 
38
42
  const state = url.searchParams.get("state");
39
43
  const code = url.searchParams.get("code");
@@ -105,7 +109,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
105
109
  session.regenerate?.();
106
110
  session.set("user", { id: user.id });
107
111
  session.set(SESSION_ID_TOKEN, tokens.idToken);
108
- return redirect(returnTo);
112
+ return flow.refresh ? fail(redirect, returnTo, "refreshed") : redirect(returnTo);
109
113
  } catch (e) {
110
114
  const code = e instanceof ShopifyError ? e.code : e instanceof OAuthError ? "signup_not_allowed" : "sign_in_failed";
111
115
  console.error("[redeemid] sign-in failed:", (e as Error).message);
@@ -2,6 +2,9 @@
2
2
  * GET /_emdash/api/auth/redeemid/login?return=/path
3
3
  *
4
4
  * Starts Shopify customer account sign-in (authorization code + PKCE).
5
+ * With `refresh=1` it asks the store not to show a form (`prompt=none`):
6
+ * a member whose store session is still open comes straight back with their
7
+ * tags read again; anyone else is sent through the usual sign-in.
5
8
  */
6
9
 
7
10
  import type { APIRoute } from "astro";
@@ -16,6 +19,7 @@ export const prerender = false;
16
19
  export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
17
20
  const url = new URL(request.url);
18
21
  const returnTo = safeReturn(url.searchParams.get("return"));
22
+ const refresh = url.searchParams.get("refresh") === "1";
19
23
  const emdash = (locals as { emdash?: { config?: unknown } }).emdash;
20
24
  const config = readConfig(emdash?.config);
21
25
  const clientId = env(ENV.clientId);
@@ -30,6 +34,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
30
34
  verifier: randomToken(48),
31
35
  returnTo,
32
36
  at: Date.now(),
37
+ ...(refresh ? { refresh: true } : {}),
33
38
  };
34
39
  session.set(SESSION_FLOW, flow);
35
40
  return redirect(
@@ -40,6 +45,7 @@ export const GET: APIRoute = async ({ request, locals, session, redirect }) => {
40
45
  nonce: flow.nonce,
41
46
  challenge: await challengeFor(flow.verifier),
42
47
  locale: request.headers.get("accept-language")?.split(",")[0]?.trim(),
48
+ ...(refresh ? { prompt: "none" as const } : {}),
43
49
  }),
44
50
  );
45
51
  } catch (e) {
@@ -11,6 +11,8 @@ export interface Flow {
11
11
  verifier: string;
12
12
  returnTo: string;
13
13
  at: number;
14
+ /** Started by Refresh membership (prompt=none). */
15
+ refresh?: boolean;
14
16
  }
15
17
 
16
18
  export const CALLBACK_PATH = "/_emdash/api/auth/redeemid/callback";
package/src/shopify.ts CHANGED
@@ -97,7 +97,16 @@ export function base64url(bytes: Uint8Array): string {
97
97
 
98
98
  export function authorizeUrl(
99
99
  d: Discovery,
100
- args: { clientId: string; redirectUri: string; state: string; nonce: string; challenge: string; locale?: string },
100
+ args: {
101
+ clientId: string;
102
+ redirectUri: string;
103
+ state: string;
104
+ nonce: string;
105
+ challenge: string;
106
+ locale?: string;
107
+ /** "none": no sign-in form; the store answers with an error when the customer isn't signed in there. */
108
+ prompt?: "none";
109
+ },
101
110
  ): string {
102
111
  const url = new URL(d.authorizationEndpoint);
103
112
  url.searchParams.set("scope", SCOPES);
@@ -109,6 +118,7 @@ export function authorizeUrl(
109
118
  url.searchParams.set("code_challenge", args.challenge);
110
119
  url.searchParams.set("code_challenge_method", "S256");
111
120
  if (args.locale) url.searchParams.set("ui_locales", args.locale);
121
+ if (args.prompt) url.searchParams.set("prompt", args.prompt);
112
122
  return url.toString();
113
123
  }
114
124
 
package/src/storage.ts CHANGED
@@ -35,8 +35,19 @@ export interface MemberRecord {
35
35
  tagsSince?: Record<string, string>;
36
36
  /** When the customer first signed in here, ISO. */
37
37
  linkedAt?: string | null;
38
+ /** Tags Shopify has removed, with when each was held (newest last, at most 50). */
39
+ pastTags?: PastTag[];
38
40
  }
39
41
 
42
+ /** A tag the customer held from `from` (when known) until `to`. */
43
+ export interface PastTag {
44
+ tag: string;
45
+ from: string | null;
46
+ to: string;
47
+ }
48
+
49
+ const PAST_TAGS_KEPT = 50;
50
+
40
51
  /** A signed-in reader's meter for one period: the locked items they opened free. */
41
52
  export interface MeterRecord {
42
53
  userId: string;
@@ -45,11 +56,18 @@ export interface MeterRecord {
45
56
  items: string[];
46
57
  }
47
58
 
48
- /** Update `tagsSince` to the record's current tags: new tags start now, removed tags are dropped. */
59
+ /**
60
+ * Update `tagsSince` to the record's current tags: new tags start now;
61
+ * removed tags move to `pastTags` with the dates they were held.
62
+ */
49
63
  export function stampTags(m: MemberRecord, now = new Date().toISOString()): MemberRecord {
50
64
  const before = m.tagsSince ?? {};
51
65
  const since: Record<string, string> = {};
52
66
  for (const t of m.tags.map((t) => t.toLowerCase())) since[t] = before[t] ?? now;
67
+ const removed = Object.keys(before).filter((t) => !(t in since));
68
+ if (removed.length) {
69
+ m.pastTags = [...(m.pastTags ?? []), ...removed.map((tag) => ({ tag, from: before[tag] ?? null, to: now }))].slice(-PAST_TAGS_KEPT);
70
+ }
53
71
  m.tagsSince = since;
54
72
  return m;
55
73
  }
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.6";
1
+ export const VERSION = "0.0.7";