@redeemid/emdash 0.0.25 → 0.0.27

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
@@ -4,13 +4,13 @@
4
4
  [![Release](https://github.com/webmultipliers/redeemid-emdash/actions/workflows/release.yml/badge.svg)](https://github.com/webmultipliers/redeemid-emdash/actions/workflows/release.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
6
6
 
7
- Memberships and paywalls for [EmDash](https://github.com/emdash-cms/emdash) sites, run by Shopify.
7
+ Shopify commerce for [EmDash](https://github.com/emdash-cms/emdash) sites: a storefront, a cart, Shopify checkout, customer accounts, and memberships and paywalls that Shopify decides.
8
8
 
9
- Readers sign in with the store's own customer account. Their Shopify customer tags decide what they can read. Shopify keeps the store, checkout, orders and subscriptions, and EmDash keeps the content. Nothing on the site grants access by itself, so there is one source of truth for who paid for what.
9
+ Shopify keeps the store, checkout, orders, subscriptions and customer accounts. EmDash keeps the content. RedeemID joins them: shoppers browse products and fill a cart on your EmDash site, check out with Shopify, and sign in with the store's own customer account. What a reader can read comes from their Shopify customer tags, so there is one source of truth for who paid for what.
10
10
 
11
11
  ![The RedeemID Access page in the EmDash admin: three levels, Member, Pro and Gold, each mapped to Shopify customer tags, with Gold including Pro](.github/images/access-levels.png)
12
12
 
13
- > **Status: 0.0.24, early.** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover every feature. Expect changes before 1.0.
13
+ > **Status: early (0.0.x).** Runs on Node and Cloudflare Workers. Unit tests and an end-to-end test that boots a real EmDash site against a fake Shopify store cover every feature. Expect changes before 1.0.
14
14
 
15
15
  ## How it works
16
16
 
@@ -24,6 +24,7 @@ Selling a membership is ordinary Shopify: a product, a checkout, and a Flow work
24
24
 
25
25
  ## Features
26
26
 
27
+ - **For shoppers:** product pages with options, subscriptions and Product structured data, a product sitemap, collections with Shopify's filters and sorting, product search, recommendations, a cart page and cart drawer, discount codes, local prices by market, and a checkout that opens signed in for members. It all works without JavaScript.
27
28
  - **For members:** sign in, Account and Sign out without ever seeing the word "Shopify"; a member card with levels, renewal and end dates; preferences saved on their customer record; download or delete what the site keeps about them.
28
29
  - **For editors:** Paywall break, Members only, Sold on its own, Plan, Product, Collection, Product search, Contribution and Preferences blocks; an Access panel on every entry; teasers, a monthly or weekly meter, drip, badges and protected file downloads.
29
30
  - **For staff:** Access, Members, Webhooks, App events, Reports, Setup, Status, Access test and Privacy pages in the EmDash admin. New content types and taxonomies appear on their own, so access never needs a code change.
@@ -1,6 +1,6 @@
1
1
  # Developer API
2
2
 
3
- What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.4.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
3
+ What site code, other plugins and AI agents can rely on in `@redeemid/emdash`. API version **1.5.0** (`API_VERSION` in `@redeemid/emdash/contract`, also in `api/state` and `window.redeemid.apiVersion`).
4
4
 
5
5
  Everything on this page is stable: it changes only with a changelog entry and an API version bump (minor when something is added, major when something changes or goes). Anything not listed is internal, even when a module exports it. `src/contract.ts` is the same list as code, and a test fails when the code, that list and this page disagree.
6
6
 
@@ -21,7 +21,7 @@ Import these in Astro pages, components and endpoints.
21
21
  | `@redeemid/emdash/features` | `hasFeature`, `viewerFeatures`, `featuresFor` |
22
22
  | `@redeemid/emdash/preferences` | `readPreferences`, `savePreferences`, `preferenceFields` |
23
23
  | `@redeemid/emdash/storefront` | `plan`, `product`, `collection`, `money`, `realOptions`, `variantFor`, `storeContext`, `cartDetails`, `addToCart`, `updateLines`, `removeLines`, `setDiscountCodes` |
24
- | `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS` |
24
+ | `@redeemid/emdash/catalog` | `searchProducts`, `collectionPage`, `suggest`, `recommendations`, `browseFromUrl`, `browseHref`, `parseFilters`, `priceFilter`, `SORTS`, `productSitemap` |
25
25
  | `@redeemid/emdash/events` | `signBody` |
26
26
  | `@redeemid/emdash/text` | `STRINGS`, `text`, `textParts` |
27
27
  | `@redeemid/emdash/search` | `readableResults` |
@@ -56,7 +56,7 @@ Under `/_emdash/api/auth/redeemid/`. Same-origin only: EmDash checks `Origin` on
56
56
  - `api/state` (GET): the visitor's own state.
57
57
 
58
58
  ```json
59
- { "apiVersion": "1.4.0", "version": "0.0.25", "signedIn": true, "linked": true,
59
+ { "apiVersion": "1.5.0", "version": "0.0.27", "signedIn": true, "linked": true,
60
60
  "levels": ["gold"], "features": ["ad-free"], "cart": { "count": 2 },
61
61
  "meter": { "used": 1, "limit": 3, "period": "month" } }
62
62
  ```
package/docs/reference.md CHANGED
@@ -98,7 +98,7 @@ Import each from `@redeemid/emdash/components/<Name>.astro`.
98
98
  | `@redeemid/emdash/features` | `hasFeature()`, `viewerFeatures()` |
99
99
  | `@redeemid/emdash/preferences` | `readPreferences()`, `savePreferences()` |
100
100
  | `@redeemid/emdash/storefront` | `product()`, `collection()`, `plan()`, `money()` and the cart calls |
101
- | `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()` |
101
+ | `@redeemid/emdash/catalog` | `searchProducts()`, `collectionPage()`, `suggest()`, `recommendations()`, `productSitemap()` |
102
102
  | `@redeemid/emdash/events` | `signBody(body, secret)`, the signature a receiver checks against `X-RedeemID-Signature` |
103
103
  | `@redeemid/emdash/text` | `STRINGS`, the list of every replaceable string |
104
104
  | `@redeemid/emdash/search` | `readableResults()`, for a search page that calls EmDash's `search()` |
package/docs/shop.md CHANGED
@@ -37,6 +37,35 @@ Everything works without JavaScript: the forms post to `/_emdash/api/auth/redeem
37
37
 
38
38
  **Local prices.** Set `market: { country: "visitor" }` to show each visitor prices in their own market (from Cloudflare's `CF-IPCountry`), or a fixed `country` and `language`. Markets are set up in Shopify.
39
39
 
40
+ **Product pages in search.** `<Product heading="h1">` prints Product structured data. Give the page the product's own title and description, and answer 404 for a handle Shopify doesn't have, by reading the product in the page (the same cached read `<Product>` makes):
41
+
42
+ ```astro
43
+ ---
44
+ // src/pages/shop/[handle].astro
45
+ import { siteConfig } from "@redeemid/emdash/access";
46
+ import { product, storeContext } from "@redeemid/emdash/storefront";
47
+
48
+ const config = await siteConfig(Astro.locals);
49
+ const p = config ? await product(config, Astro.params.handle, undefined, storeContext(config, Astro.request)).catch(() => null) : null;
50
+ if (!p) Astro.response.status = 404;
51
+ const title = p?.seo?.title || p?.title;
52
+ const description = p?.seo?.description || p?.description.slice(0, 160);
53
+ ---
54
+ ```
55
+
56
+ EmDash's sitemap lists content entries only, so add one for products. `productSitemap()` lists every product the Headless channel sells (up to 5,000), newest change first, linked with the same pattern as the cards:
57
+
58
+ ```ts
59
+ // src/pages/sitemap-products.xml.ts
60
+ import { siteConfig } from "@redeemid/emdash/access";
61
+ import { productSitemap } from "@redeemid/emdash/catalog";
62
+ import type { APIRoute } from "astro";
63
+
64
+ export const GET: APIRoute = async ({ locals, url }) => productSitemap(await siteConfig(locals), url, { productHref: "/shop/{handle}/" });
65
+ ```
66
+
67
+ Then submit it to search engines, or list it in robots.txt. A custom robots.txt in EmDash's SEO settings replaces the generated one, so keep both lines in it: `Sitemap: https://<your site>/sitemap.xml` and `Sitemap: https://<your site>/sitemap-products.xml`. If the store's Online Store channel is open too, search engines find two pages for each product; headless stores usually password-protect the Online Store or redirect it to the site.
68
+
40
69
  **Shoppers' agents.** Shopify runs a Storefront MCP server for every store at `https://<store domain>/api/mcp`, for AI agents that browse, build a cart and check out. Point shopping agents there; RedeemID's own tools are for staff.
41
70
 
42
71
  ## Preferences
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@redeemid/emdash",
3
- "version": "0.0.25",
4
- "description": "Shopify customer accounts, member levels and paywalls for EmDash sites. Shopify runs commerce; EmDash runs content.",
3
+ "version": "0.0.27",
4
+ "description": "Shopify commerce for EmDash sites: storefront, cart, checkout, customer accounts, memberships and paywalls. Shopify runs commerce; EmDash runs content.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Vinny S. Green, LLC",
@@ -39,7 +39,15 @@
39
39
  ],
40
40
  "keywords": [
41
41
  "emdash",
42
+ "emdash-plugin",
42
43
  "shopify",
44
+ "headless-commerce",
45
+ "ecommerce",
46
+ "storefront",
47
+ "cart",
48
+ "checkout",
49
+ "astro",
50
+ "cloudflare-workers",
43
51
  "paywall",
44
52
  "membership",
45
53
  "customer-accounts"
@@ -3,4 +3,4 @@
3
3
  * when something is added, the major part when something stable changes or
4
4
  * goes. Separate from the package version.
5
5
  */
6
- export const API_VERSION = "1.4.0";
6
+ export const API_VERSION = "1.5.0";
package/src/catalog.ts CHANGED
@@ -285,3 +285,81 @@ export function browseHref(current: URL, change: Record<string, string | null>):
285
285
  }
286
286
 
287
287
  export type { Money };
288
+
289
+ const SITEMAP_QUERY = `query SitemapProducts($first: Int!, $after: String) {
290
+ products(first: $first, after: $after, sortKey: UPDATED_AT, reverse: true) {
291
+ pageInfo { hasNextPage endCursor }
292
+ nodes { handle updatedAt }
293
+ }
294
+ }`;
295
+
296
+ /** One product in the sitemap: its handle and when Shopify last changed it. */
297
+ export interface SitemapProduct {
298
+ handle: string;
299
+ updatedAt: string;
300
+ }
301
+
302
+ /** The most products one sitemap lists (20 Storefront API pages of 250). */
303
+ export const SITEMAP_LIMIT = 5000;
304
+
305
+ function escapeXml(s: string): string {
306
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
307
+ }
308
+
309
+ /**
310
+ * Every product the site's sales channel can sell, newest change first, up
311
+ * to SITEMAP_LIMIT. Cached for five minutes.
312
+ */
313
+ export async function sitemapProducts(config: RedeemIDConfig, opts: { fetcher?: typeof fetch } = {}): Promise<SitemapProduct[]> {
314
+ return cachedRead(`${shopOrigin(config)}|sitemap`, async () => {
315
+ const out: SitemapProduct[] = [];
316
+ let after: string | null = null;
317
+ while (out.length < SITEMAP_LIMIT) {
318
+ type Data = { products: { pageInfo: { hasNextPage: boolean; endCursor: string | null }; nodes: SitemapProduct[] } };
319
+ const data: Data = await storefront<Data>(config, SITEMAP_QUERY, { first: 250, after }, opts);
320
+ out.push(...data.products.nodes.filter((n) => typeof n.handle === "string" && n.handle));
321
+ if (!data.products.pageInfo.hasNextPage || !data.products.pageInfo.endCursor) break;
322
+ after = data.products.pageInfo.endCursor;
323
+ }
324
+ return out.slice(0, SITEMAP_LIMIT);
325
+ });
326
+ }
327
+
328
+ /**
329
+ * A sitemap of the site's product pages, for search engines. EmDash's own
330
+ * sitemap lists content entries only, so products need this one:
331
+ *
332
+ * // src/pages/sitemap-products.xml.ts
333
+ * export const GET: APIRoute = async ({ locals, url }) =>
334
+ * productSitemap(await siteConfig(locals), url, { productHref: "/shop/{handle}/" });
335
+ *
336
+ * `productHref` is the same pattern the product cards link with. Answers an
337
+ * empty sitemap (not an error) when the store can't be read, so a crawler
338
+ * never caches a broken page.
339
+ */
340
+ export async function productSitemap(
341
+ config: RedeemIDConfig | null,
342
+ url: URL,
343
+ opts: { productHref: string; fetcher?: typeof fetch },
344
+ ): Promise<Response> {
345
+ let products: SitemapProduct[] = [];
346
+ if (config) {
347
+ try {
348
+ products = await sitemapProducts(config, { fetcher: opts.fetcher });
349
+ } catch {
350
+ products = [];
351
+ }
352
+ }
353
+ const lines = ['<?xml version="1.0" encoding="UTF-8"?>', '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">'];
354
+ for (const p of products) {
355
+ const loc = new URL(opts.productHref.replace("{handle}", encodeURIComponent(p.handle)), url).href;
356
+ lines.push(" <url>", ` <loc>${escapeXml(loc)}</loc>`);
357
+ if (p.updatedAt) lines.push(` <lastmod>${escapeXml(p.updatedAt)}</lastmod>`);
358
+ lines.push(" </url>");
359
+ }
360
+ lines.push("</urlset>");
361
+ return new Response(lines.join("\n"), {
362
+ status: 200,
363
+ headers: { "Content-Type": "application/xml; charset=utf-8", "Cache-Control": "public, max-age=3600" },
364
+ });
365
+ }
package/src/contract.ts CHANGED
@@ -20,7 +20,7 @@ export const MODULES = {
20
20
  "@redeemid/emdash/features": ["hasFeature", "viewerFeatures", "featuresFor"],
21
21
  "@redeemid/emdash/preferences": ["readPreferences", "savePreferences", "preferenceFields"],
22
22
  "@redeemid/emdash/storefront": ["plan", "product", "collection", "money", "realOptions", "variantFor", "storeContext", "cartDetails", "addToCart", "updateLines", "removeLines", "setDiscountCodes"],
23
- "@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS"],
23
+ "@redeemid/emdash/catalog": ["searchProducts", "collectionPage", "suggest", "recommendations", "browseFromUrl", "browseHref", "parseFilters", "priceFilter", "SORTS", "productSitemap"],
24
24
  "@redeemid/emdash/events": ["signBody"],
25
25
  "@redeemid/emdash/text": ["STRINGS", "text", "textParts"],
26
26
  "@redeemid/emdash/search": ["readableResults"],
@@ -80,6 +80,15 @@ export function hasMemberBlocks(data: Record<string, unknown> | null | undefined
80
80
  return false;
81
81
  }
82
82
 
83
+ /**
84
+ * An entry as EmDash's queries return it. `data` is `object` so the
85
+ * interfaces `emdash types` generates for each collection fit.
86
+ */
87
+ export interface CacheableEntry {
88
+ id?: string;
89
+ data?: object | null;
90
+ }
91
+
83
92
  /**
84
93
  * May this entry's page be stored and served to everyone? Only when it reads
85
94
  * the same for every visitor, now and until the cache could expire.
@@ -87,18 +96,19 @@ export function hasMemberBlocks(data: Record<string, unknown> | null | undefined
87
96
  export async function cacheable(
88
97
  astro: AstroLike,
89
98
  collection: string,
90
- entry: { id?: string; data?: Record<string, unknown> | null } | null | undefined,
99
+ entry: CacheableEntry | null | undefined,
91
100
  ): Promise<boolean> {
92
101
  if (!entry) return false;
102
+ const data = entry.data as Record<string, unknown> | null | undefined;
93
103
  const config = await siteConfig(astro.locals);
94
- if (!config) return !hasMemberBlocks(entry.data);
104
+ if (!config) return !hasMemberBlocks(data);
95
105
  if (config.unavailable) return false;
96
- const terms = entry.data?.terms as Terms | undefined;
106
+ const terms = data?.terms as Terms | undefined;
97
107
  if ((await entryRequirement(config, collection, terms, entry.id)) !== null) return false;
98
108
  // Free until a date: a stored copy would outlive the window.
99
109
  const own = entryRuleFor(config, collection, entry.id);
100
110
  if (own?.openUntil && Date.now() < Date.parse(own.openUntil)) return false;
101
- return !hasMemberBlocks(entry.data);
111
+ return !hasMemberBlocks(data);
102
112
  }
103
113
 
104
114
  /**
@@ -108,7 +118,7 @@ export async function cacheable(
108
118
  export async function cacheEntry(
109
119
  astro: CacheAstro,
110
120
  collection: string,
111
- entry: { id?: string; data?: Record<string, unknown> | null } | null | undefined,
121
+ entry: CacheableEntry | null | undefined,
112
122
  hint?: Hint | null,
113
123
  ): Promise<boolean> {
114
124
  if (keptOut(astro.locals)) return false;
package/src/version.ts CHANGED
@@ -1 +1 @@
1
- export const VERSION = "0.0.25";
1
+ export const VERSION = "0.0.27";