@redeemid/emdash 0.0.24 → 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 +47 -638
- package/docs/access.md +131 -0
- package/docs/content.md +84 -0
- package/docs/developer-api.md +25 -5
- package/docs/getting-started.md +94 -0
- package/docs/production.md +52 -0
- package/docs/reference.md +128 -0
- package/docs/shop.md +94 -0
- package/docs/shopify-flow.md +1 -1
- package/docs/shopify-setup.md +4 -4
- package/docs/staff.md +81 -0
- package/package.json +11 -2
- package/src/api-version.ts +1 -1
- package/src/catalog.ts +78 -0
- package/src/components/Content.astro +7 -1
- package/src/components/Gate.astro +3 -0
- package/src/components/MemberCard.astro +3 -0
- package/src/components/Plan.astro +3 -0
- package/src/components/Preferences.astro +3 -0
- package/src/components/YourData.astro +3 -0
- package/src/contract.ts +2 -1
- package/src/guard.ts +23 -2
- package/src/plugin.ts +19 -2
- package/src/route-cache.ts +134 -0
- package/src/settings.ts +9 -0
- package/src/template-scan.ts +33 -6
- package/src/version.ts +1 -1
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Astro's route cache, made safe for gated content.
|
|
3
|
+
*
|
|
4
|
+
* The route cache keys pages by URL and serves a stored page without running
|
|
5
|
+
* any code, middleware included. So a page cached for one reader reaches the
|
|
6
|
+
* next one whoever they are: a visitor's copy (wall included) reaches a
|
|
7
|
+
* member, and a page cached while its entry was free keeps showing all of it
|
|
8
|
+
* after a rule locks it. Use these in page frontmatter instead of calling
|
|
9
|
+
* `Astro.cache.set()` yourself:
|
|
10
|
+
*
|
|
11
|
+
* const { entry: post, cacheHint } = await getEmDashEntry("posts", slug);
|
|
12
|
+
* await cacheEntry(Astro, "posts", post, cacheHint);
|
|
13
|
+
*
|
|
14
|
+
* const { entries, cacheHint } = await getEmDashCollection("posts");
|
|
15
|
+
* cachePage(Astro, cacheHint);
|
|
16
|
+
*
|
|
17
|
+
* cacheEntry caches the page only when it reads the same for everyone: no
|
|
18
|
+
* rule covers the entry, no open window is running out, and the body has no
|
|
19
|
+
* Members only part, single sale, Plan or Preferences block. Otherwise the
|
|
20
|
+
* page stays out of the route cache, even when a layout sets a hint later:
|
|
21
|
+
* redeemidGuard() opts it out again after the page renders.
|
|
22
|
+
*
|
|
23
|
+
* RedeemID's member-aware components (Content for a locked body, Gate, Plan,
|
|
24
|
+
* MemberCard, Preferences, YourData) call noRouteCache() themselves as a
|
|
25
|
+
* safety net, for pages that set hints by hand or sites with route rules.
|
|
26
|
+
*
|
|
27
|
+
* Both tag the page `redeemid`. Saving on the Access page or an entry's
|
|
28
|
+
* Access panel purges that tag, so cached pages follow the new rules.
|
|
29
|
+
*
|
|
30
|
+
* The cache still serves its copy to signed-in members (no code runs on a
|
|
31
|
+
* hit), so keep member-aware chrome on cached pages, such as the header's
|
|
32
|
+
* Account link, in the browser (window.redeemid) or out of cached pages.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { entryRequirement, entryRuleFor, type AstroLike, type Terms } from "./access.js";
|
|
36
|
+
import { ITEM_BLOCK, MEMBERS_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK } from "./blocks-names.js";
|
|
37
|
+
import { siteConfig } from "./site-config.js";
|
|
38
|
+
|
|
39
|
+
/** The cache tag RedeemID puts on pages it lets into the route cache. */
|
|
40
|
+
export const CACHE_TAG = "redeemid";
|
|
41
|
+
|
|
42
|
+
/** Blocks whose output depends on who reads them. */
|
|
43
|
+
export const MEMBER_BLOCKS: readonly string[] = [MEMBERS_BLOCK, ITEM_BLOCK, PLAN_BLOCK, PREFERENCES_BLOCK];
|
|
44
|
+
|
|
45
|
+
type Hint = { tags?: string[]; maxAge?: number; swr?: number; lastModified?: Date; etag?: string };
|
|
46
|
+
interface RouteCache {
|
|
47
|
+
enabled?: boolean;
|
|
48
|
+
set(options: Hint | false): void;
|
|
49
|
+
}
|
|
50
|
+
type CacheAstro = AstroLike & { cache?: RouteCache };
|
|
51
|
+
|
|
52
|
+
const KEEP_OUT = Symbol.for("redeemid:keep-out-of-route-cache");
|
|
53
|
+
|
|
54
|
+
/** Did page code ask to keep this request out of the route cache? (Read by redeemidGuard.) */
|
|
55
|
+
export function keptOut(locals: unknown): boolean {
|
|
56
|
+
return Boolean(locals && typeof locals === "object" && (locals as Record<symbol, unknown>)[KEEP_OUT]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Keep this page out of the route cache, whatever sets a hint later. Its
|
|
61
|
+
* Cache-Control stays as it was: a visitor's copy is the same for every
|
|
62
|
+
* visitor, and redeemidGuard() already makes signed-in pages private.
|
|
63
|
+
*/
|
|
64
|
+
export function noRouteCache(astro: CacheAstro): void {
|
|
65
|
+
if (astro.locals && typeof astro.locals === "object") (astro.locals as Record<symbol, unknown>)[KEEP_OUT] = true;
|
|
66
|
+
astro.cache?.set(false);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Cache a page that shows no gated body (a list, a page RedeemID doesn't touch), tagged so Access saves purge it. */
|
|
70
|
+
export function cachePage(astro: CacheAstro, hint?: Hint | null): void {
|
|
71
|
+
if (keptOut(astro.locals) || !astro.cache?.enabled) return;
|
|
72
|
+
astro.cache.set({ ...(hint ?? {}), tags: [...(hint?.tags ?? []), CACHE_TAG] });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Does a body hold a block that reads differently for members? Looks at every field of the entry's data. */
|
|
76
|
+
export function hasMemberBlocks(data: Record<string, unknown> | null | undefined): boolean {
|
|
77
|
+
for (const v of Object.values(data ?? {})) {
|
|
78
|
+
if (Array.isArray(v) && v.some((b) => MEMBER_BLOCKS.includes((b as { _type?: string })?._type ?? ""))) return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* May this entry's page be stored and served to everyone? Only when it reads
|
|
85
|
+
* the same for every visitor, now and until the cache could expire.
|
|
86
|
+
*/
|
|
87
|
+
export async function cacheable(
|
|
88
|
+
astro: AstroLike,
|
|
89
|
+
collection: string,
|
|
90
|
+
entry: { id?: string; data?: Record<string, unknown> | null } | null | undefined,
|
|
91
|
+
): Promise<boolean> {
|
|
92
|
+
if (!entry) return false;
|
|
93
|
+
const config = await siteConfig(astro.locals);
|
|
94
|
+
if (!config) return !hasMemberBlocks(entry.data);
|
|
95
|
+
if (config.unavailable) return false;
|
|
96
|
+
const terms = entry.data?.terms as Terms | undefined;
|
|
97
|
+
if ((await entryRequirement(config, collection, terms, entry.id)) !== null) return false;
|
|
98
|
+
// Free until a date: a stored copy would outlive the window.
|
|
99
|
+
const own = entryRuleFor(config, collection, entry.id);
|
|
100
|
+
if (own?.openUntil && Date.now() < Date.parse(own.openUntil)) return false;
|
|
101
|
+
return !hasMemberBlocks(entry.data);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Cache an entry's page with its hint when it reads the same for everyone;
|
|
106
|
+
* otherwise keep it out of the route cache. Returns whether it was cached.
|
|
107
|
+
*/
|
|
108
|
+
export async function cacheEntry(
|
|
109
|
+
astro: CacheAstro,
|
|
110
|
+
collection: string,
|
|
111
|
+
entry: { id?: string; data?: Record<string, unknown> | null } | null | undefined,
|
|
112
|
+
hint?: Hint | null,
|
|
113
|
+
): Promise<boolean> {
|
|
114
|
+
if (keptOut(astro.locals)) return false;
|
|
115
|
+
if (!(await cacheable(astro, collection, entry))) {
|
|
116
|
+
noRouteCache(astro);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
if (!astro.cache?.enabled) return false;
|
|
120
|
+
cachePage(astro, hint);
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ------------------------------------------------------------------ purging
|
|
125
|
+
|
|
126
|
+
/** Purge RedeemID's cached pages. Never throws: the save it follows already happened. */
|
|
127
|
+
export async function purgeRouteCache(cache: { enabled?: boolean; invalidate?(o: { tags: string[] }): Promise<unknown> } | undefined): Promise<void> {
|
|
128
|
+
if (!cache?.enabled || !cache.invalidate) return;
|
|
129
|
+
try {
|
|
130
|
+
await cache.invalidate({ tags: [CACHE_TAG] });
|
|
131
|
+
} catch (e) {
|
|
132
|
+
console.error("[redeemid] couldn't purge the route cache:", (e as Error).message);
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/settings.ts
CHANGED
|
@@ -159,9 +159,18 @@ export function forgetAccess(): void {
|
|
|
159
159
|
held()[HELD] = undefined;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
const WRITES = Symbol.for("redeemid:access-writes");
|
|
163
|
+
|
|
164
|
+
/** How many times this server has saved access settings. redeemidGuard() purges the route cache when a request moves it. */
|
|
165
|
+
export function accessWrites(): number {
|
|
166
|
+
return (globalThis as unknown as Record<symbol, number | undefined>)[WRITES] ?? 0;
|
|
167
|
+
}
|
|
168
|
+
|
|
162
169
|
/** Record that the settings changed, so every server reads them again. */
|
|
163
170
|
export async function touchAccess(store: StorageCollection<AccessRecord>): Promise<void> {
|
|
164
171
|
forgetAccess();
|
|
172
|
+
const g = globalThis as unknown as Record<symbol, number | undefined>;
|
|
173
|
+
g[WRITES] = (g[WRITES] ?? 0) + 1;
|
|
165
174
|
await store.put(STAMP_ID, { kind: "stamp", version: crypto.randomUUID() });
|
|
166
175
|
}
|
|
167
176
|
|
package/src/template-scan.ts
CHANGED
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
* every rule, Paywall break and Members only part, so a gated collection
|
|
5
5
|
* rendered that way reaches every visitor in full.
|
|
6
6
|
*
|
|
7
|
+
* Also templates that call `Astro.cache.set()` themselves on a gated
|
|
8
|
+
* collection's pages instead of cacheEntry()/cachePage() (route-cache.ts):
|
|
9
|
+
* the route cache then serves one reader's copy to everyone.
|
|
10
|
+
*
|
|
7
11
|
* redeemidGuard() scans the site's src/ at build time (scanTemplate, in
|
|
8
12
|
* Node) and hands the findings to the running site; the Status page then
|
|
9
13
|
* compares them with the rules as they are now (gatedFindings), because
|
|
@@ -17,8 +21,10 @@ import type { RedeemIDConfig } from "./config.js";
|
|
|
17
21
|
export interface TemplateFinding {
|
|
18
22
|
/** Path from the site's root, e.g. src/pages/[slug].astro. */
|
|
19
23
|
file: string;
|
|
20
|
-
/** Lines with a <PortableText> tag. */
|
|
24
|
+
/** Lines with a <PortableText> tag (from emdash/ui). */
|
|
21
25
|
lines: number[];
|
|
26
|
+
/** Lines that call Astro.cache.set() directly; absent when there are none. */
|
|
27
|
+
cache?: number[];
|
|
22
28
|
/** Collections the file loads with getEmDashEntry or getEmDashCollection. */
|
|
23
29
|
collections: string[];
|
|
24
30
|
}
|
|
@@ -26,16 +32,20 @@ export interface TemplateFinding {
|
|
|
26
32
|
const FROM_EMDASH_UI = /import\s*\{[^}]*\bPortableText\b[^}]*\}\s*from\s*["']emdash\/ui["']/;
|
|
27
33
|
const LOADS = /getEmDash(?:Entry|Collection)\(\s*["'`]([\w-]+)["'`]/g;
|
|
28
34
|
|
|
29
|
-
|
|
35
|
+
const CACHE_SET = /\bAstro\.cache\??\.set\(/;
|
|
36
|
+
|
|
37
|
+
/** One template's finding, or null when it neither renders <PortableText> from emdash/ui nor sets a cache hint. */
|
|
30
38
|
export function scanTemplate(file: string, source: string): TemplateFinding | null {
|
|
31
|
-
|
|
39
|
+
const fromUi = FROM_EMDASH_UI.test(source);
|
|
32
40
|
const lines: number[] = [];
|
|
41
|
+
const cache: number[] = [];
|
|
33
42
|
source.split("\n").forEach((line, i) => {
|
|
34
|
-
if (/<PortableText[\s/>]/.test(line)) lines.push(i + 1);
|
|
43
|
+
if (fromUi && /<PortableText[\s/>]/.test(line)) lines.push(i + 1);
|
|
44
|
+
if (CACHE_SET.test(line)) cache.push(i + 1);
|
|
35
45
|
});
|
|
36
|
-
if (!lines.length) return null;
|
|
46
|
+
if (!lines.length && !cache.length) return null;
|
|
37
47
|
const collections = [...new Set([...source.matchAll(LOADS)].map((m) => m[1]!))].sort();
|
|
38
|
-
return { file, lines, collections };
|
|
48
|
+
return cache.length ? { file, lines, cache, collections } : { file, lines, collections };
|
|
39
49
|
}
|
|
40
50
|
|
|
41
51
|
/** Collections some rule or entry rule can lock: "*" when the site default does. */
|
|
@@ -61,12 +71,29 @@ export function gatedFindings(findings: TemplateFinding[], config: RedeemIDConfi
|
|
|
61
71
|
if (!gated.size) return [];
|
|
62
72
|
const all = gated.has("*");
|
|
63
73
|
return findings.flatMap((f) => {
|
|
74
|
+
if (!f.lines.length) return [];
|
|
64
75
|
if (!f.collections.length) return [{ ...f, gated: [] }];
|
|
65
76
|
const hit = f.collections.filter((c) => all || gated.has(c));
|
|
66
77
|
return hit.length ? [{ ...f, gated: hit }] : [];
|
|
67
78
|
});
|
|
68
79
|
}
|
|
69
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Templates that set a route cache hint themselves on pages that load a
|
|
83
|
+
* gated collection. Files whose collection we can't see (layouts, which set
|
|
84
|
+
* hints for menus and settings) aren't listed: cacheEntry() on the page keeps
|
|
85
|
+
* those out anyway.
|
|
86
|
+
*/
|
|
87
|
+
export function cacheFindings(findings: TemplateFinding[], config: RedeemIDConfig): GatedFinding[] {
|
|
88
|
+
const gated = gatedCollections(config);
|
|
89
|
+
if (!gated.size) return [];
|
|
90
|
+
const all = gated.has("*");
|
|
91
|
+
return findings.flatMap((f) => {
|
|
92
|
+
const hit = f.collections.filter((c) => all || gated.has(c));
|
|
93
|
+
return f.cache?.length && hit.length ? [{ ...f, gated: hit }] : [];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
/** Where the running site keeps the scan; unset when redeemidGuard() isn't installed. */
|
|
71
98
|
export const TEMPLATES_KEY = "__redeemidTemplates";
|
|
72
99
|
|
package/src/version.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const VERSION = "0.0.
|
|
1
|
+
export const VERSION = "0.0.26";
|