@redeemid/emdash 0.0.25 → 0.0.26
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 +4 -3
- package/docs/developer-api.md +3 -3
- package/docs/reference.md +1 -1
- package/docs/shop.md +29 -0
- package/package.json +10 -2
- package/src/api-version.ts +1 -1
- package/src/catalog.ts +78 -0
- package/src/contract.ts +1 -1
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -4,13 +4,13 @@
|
|
|
4
4
|
[](https://github.com/webmultipliers/redeemid-emdash/actions/workflows/release.yml)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
|
|
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
|
-
|
|
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
|

|
|
12
12
|
|
|
13
|
-
> **Status: 0.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.
|
package/docs/developer-api.md
CHANGED
|
@@ -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.
|
|
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.
|
|
59
|
+
{ "apiVersion": "1.5.0", "version": "0.0.26", "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.
|
|
4
|
-
"description": "Shopify customer accounts,
|
|
3
|
+
"version": "0.0.26",
|
|
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"
|
package/src/api-version.ts
CHANGED
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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"],
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.26";
|