@redeemid/emdash 0.0.30 → 0.0.31

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.
@@ -16,6 +16,8 @@
16
16
  import type { StorageCollection } from "emdash";
17
17
 
18
18
  import { applyEntryRule, badgeColor, describe, requirement, ruleSource, SIGNED_IN, termTree, type Requirement, type Terms } from "./access.js";
19
+ import { accessMap, type MapGo } from "./access-map.js";
20
+ import { accessTest } from "./staff.js";
19
21
  import { groupsOf, type FeatureConfig, type FileRule, type LevelConfig, type MeterConfig, type RedeemIDConfig, type RuleConfig, type TagGroup } from "./config.js";
20
22
  import {
21
23
  days,
@@ -62,12 +64,30 @@ export interface AccessEnv {
62
64
  taxonomies: TaxonomyInfo[];
63
65
  /** Terms of a taxonomy, parents before children, labels showing the path. */
64
66
  terms(taxonomy: string): Promise<TermInfo[]>;
67
+ /** Members holding each level, for the map (left out in tests). */
68
+ holders?(): Promise<Record<string, number>>;
65
69
  }
66
70
 
67
- export const TABS = ["levels", "site", "types", "taxonomies", "features", "files"] as const;
71
+ export const TABS = ["map", "levels", "site", "types", "taxonomies", "features", "files", "test"] as const;
68
72
  export type Tab = (typeof TABS)[number];
69
- /** Which of the four panels holds each part. */
70
- const PANEL: Record<Tab, number> = { levels: 0, features: 0, site: 1, types: 1, taxonomies: 2, files: 3 };
73
+ /** Which panel holds each part: Map, Levels, Features, Rules, Terms, Files, Test. */
74
+ const PANEL: Record<Tab, number> = { map: 0, levels: 1, features: 2, site: 3, types: 3, taxonomies: 4, files: 5, test: 6 };
75
+
76
+ /**
77
+ * A button that opens a dialog around `blocks` (rendered by admin.ts). With
78
+ * `open` and no `label` it opens straight away, for Edit picked in a table.
79
+ */
80
+ function modal(id: string, title: string, blocks: unknown[], opts: { label?: string; style?: "primary" | "secondary"; description?: string; open?: boolean } = {}) {
81
+ return {
82
+ type: "redeemid_modal",
83
+ block_id: `modal:${id}`,
84
+ title,
85
+ ...(opts.description ? { description: opts.description } : {}),
86
+ ...(opts.label ? { trigger: { label: opts.label, style: opts.style ?? "primary" } } : {}),
87
+ ...(opts.open ? { open: true } : {}),
88
+ blocks,
89
+ };
90
+ }
71
91
 
72
92
  const effective = (env: AccessEnv) => mergeAccess(env.code, env.saved) ?? env.code;
73
93
 
@@ -189,16 +209,78 @@ export function parseGroups(raw: unknown, firstMatch?: unknown): TagGroup[] {
189
209
 
190
210
  // ---------------------------------------------------------------- tabs
191
211
 
212
+ const API_DOCS = "https://github.com/webmultipliers/redeemid-emdash/blob/main/docs/developer-api.md";
213
+
214
+ /** Astro code that checks a level, with its slug filled in. */
215
+ function levelCode(slug: string): string {
216
+ return `---
217
+ import Gate from "@redeemid/emdash/components/Gate.astro";
218
+ import { viewer } from "@redeemid/emdash/access";
219
+ const reader = await viewer(Astro);
220
+ ---
221
+ <Gate levels={["${slug}"]}>
222
+ <p>Only this level sees this part.</p>
223
+ <p slot="locked">Join to read it.</p>
224
+ </Gate>
225
+ {reader.levels.includes("${slug}") && <p>Thanks for being a member.</p>}`;
226
+ }
227
+
228
+ /** Astro code that checks a feature, with its slug filled in. */
229
+ function featureCode(slug: string): string {
230
+ return `---
231
+ import Feature from "@redeemid/emdash/components/Feature.astro";
232
+ import { hasFeature } from "@redeemid/emdash/features";
233
+ const on = await hasFeature(Astro, "${slug}");
234
+ ---
235
+ <Feature slug="${slug}">
236
+ <p>Shown to members who hold it.</p>
237
+ <p slot="else">Shown to everyone else.</p>
238
+ </Feature>
239
+ {!on && <aside class="ad">…</aside>}`;
240
+ }
241
+
242
+ /**
243
+ * A closed "Use it in your site" section: who grants it (Shopify), the code
244
+ * that checks it, and where the full API is.
245
+ */
246
+ function inSite(kind: "level" | "feature", slug: string, named?: string): unknown {
247
+ const grant = "Who holds it is decided in Shopify: Flow, a subscription app, or staff on a member's Tags tab. Nothing on this site grants it.";
248
+ const what =
249
+ kind === "level"
250
+ ? "Content rules here lock posts to a level without code. In templates, <Gate levels> locks part of a page, and viewer(Astro).levels or membership() says what the reader holds. In the browser, window.redeemid's state event lists the reader's levels."
251
+ : "A feature does nothing until the site's code checks it. Use <Feature slug> to show or hide part of a page, or hasFeature(Astro, slug) in code. Protected folders on the Files tab can also open to a feature. In the browser, window.redeemid's state event lists the reader's features.";
252
+ return {
253
+ type: "accordion",
254
+ label: named ? `Use ${named} in your site` : "Use it in your site",
255
+ blocks: [
256
+ { type: "context", text: grant },
257
+ { type: "context", text: what },
258
+ { type: "code", language: "astro", code: kind === "level" ? levelCode(slug) : featureCode(slug) },
259
+ { type: "actions", elements: [{ type: "link", label: "Developer API", target: { kind: "external", url: API_DOCS }, appearance: "secondary" }] },
260
+ ],
261
+ };
262
+ }
263
+
264
+ /** The whole picture: who gets what, and through which level. */
265
+ async function mapTab(env: AccessEnv): Promise<unknown[]> {
266
+ const config = effective(env);
267
+ const holders = env.holders ? await env.holders().catch(() => undefined) : undefined;
268
+ const rows = accessMap({ config, entries: env.saved.entries, collections: env.collections, taxonomies: env.taxonomies, tagWords: groupsWords, holders });
269
+ return [
270
+ { type: "context", text: "Who gets what. Pick a row to see everything it opens and where to change it. A level also gets what its included levels, signed-in members and everyone get, counted as \"via\". This shows which settings name a reader; to see which rule wins on one post, use the Test tab." },
271
+ { type: "redeemid_map", action_id: "map_go", rows },
272
+ ];
273
+ }
274
+
192
275
  function levelsTab(env: AccessEnv, editing?: string): unknown[] {
193
276
  const config = effective(env);
194
277
  const levels = config.levels ?? [];
195
278
  const rules = config.rules ?? [];
196
279
  const used = (slug: string) => rules.filter((r) => r.levels.includes(slug)).length;
197
280
  const current = editing ? levels.find((l) => l.slug === editing) : undefined;
198
- const others = levels.filter((l) => l.slug !== current?.slug);
199
281
  return [
200
- { type: "context", text: "A level is a name for readers who hold some Shopify customer tags. Name them anything. Tags come from Shopify (Flow, a subscription app or staff), never from this site. A level that includes another reads everything for it too, so a rule names only the lowest level that may read." },
201
- ...sourceLine(env, "levels", "Levels"),
282
+ { type: "context", text: "A level names the readers who hold some Shopify customer tags, and rules lock content to it. Tags come from Shopify (Flow, a subscription app or staff), never from this site. A level that includes another also reads everything that one reads. Features (next tab) add perks on top." },
283
+ modal("level:new", "Add a level", levelForm(levels), { label: "Add a level", description: TAG_HELP }),
202
284
  levels.length
203
285
  ? {
204
286
  type: "table",
@@ -221,35 +303,40 @@ function levelsTab(env: AccessEnv, editing?: string): unknown[] {
221
303
  act: { type: "menu", action_id: "level_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${l.slug}` }, { label: "Remove", value: `remove:${l.slug}` }] },
222
304
  })),
223
305
  }
224
- : { type: "empty", title: "No levels yet", description: "Add one below, such as Members for the tag member.", size: "sm" },
225
- { type: "header", text: current ? `Edit ${current.name}` : "Add a level" },
226
- { type: "context", text: "Shopify tags: a line passes when the customer has any one of its tags; start a line with all: to need every tag on it. Any one line grants the level, so gold on one line and all: silver, annual on the next means Gold, or Silver with Annual." },
306
+ : { type: "empty", title: "No levels yet", description: "Add one, such as Members for the Shopify tag member.", size: "sm" },
307
+ inSite("level", levels[0]?.slug ?? "members"),
308
+ ...(current ? [modal(`level:${current.slug}`, `Edit ${current.name}`, [...levelForm(levels, current), inSite("level", current.slug, current.name)], { open: true, description: TAG_HELP })] : []),
309
+ ...sourceLine(env, "levels", "Levels"),
310
+ ];
311
+ }
312
+
313
+ const TAG_HELP = "Shopify tags: one group per line, commas between tags. A line passes when the customer has any one of its tags; start it with all: to need every tag. Any line grants the level.";
314
+
315
+ /** The level form: what a level needs first, the optional extras behind a switch. */
316
+ function levelForm(levels: LevelConfig[], current?: LevelConfig): unknown[] {
317
+ const others = levels.filter((l) => l.slug !== current?.slug);
318
+ const extras = Boolean(current && (current.wall || current.joinUrl || current.products?.length || current.home || current.badge));
319
+ const more = { field: "more", eq: true };
320
+ return [
227
321
  {
228
322
  type: "form",
229
323
  block_id: current ? `level:${current.slug}` : "level:new",
230
324
  fields: [
231
325
  { type: "text_input", action_id: "name", label: "Name", placeholder: "Members", initial_value: current?.name ?? "" },
232
326
  ...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "members" }]),
233
- {
234
- type: "text_input",
235
- action_id: "tags",
236
- label: "Shopify tags: commas between tags, one group per line",
237
- placeholder: "gold\nall: silver, annual",
238
- initial_value: groupsText(current),
239
- multiline: true,
240
- },
327
+ { type: "text_input", action_id: "tags", label: "Shopify tags", placeholder: "gold\nall: silver, annual", initial_value: groupsText(current), multiline: true },
241
328
  ...(others.length
242
- ? [{ type: "checkbox", action_id: "includes", label: "Also opens what these levels read", options: others.map((l) => ({ label: l.name, value: l.slug })), initial_value: current?.includes ?? [] }]
329
+ ? [{ type: "checkbox", action_id: "includes", label: "Also reads what these levels read", options: others.map((l) => ({ label: l.name, value: l.slug })), initial_value: current?.includes ?? [] }]
243
330
  : []),
244
- { type: "text_input", action_id: "wall", label: "Wall text (optional)", initial_value: current?.wall ?? "", multiline: true },
245
- { type: "text_input", action_id: "join_url", label: "Join link (optional)", placeholder: "/join/", initial_value: current?.joinUrl ?? "" },
246
- { type: "text_input", action_id: "products", label: "Shopify product handles that lead to it (optional, for plan cards)", placeholder: "gold-membership", initial_value: current?.products?.join(", ") ?? "" },
247
- { type: "text_input", action_id: "home", label: "Members' home after sign-in (optional)", placeholder: "/members/gold/", initial_value: current?.home ?? "" },
248
- { type: "text_input", action_id: "badge", label: "Badge colour (optional)", placeholder: "#c9a227", initial_value: current?.badge ?? "" },
331
+ { type: "toggle", action_id: "more", label: "More options", description: "Wall text, join link, plan products, members' home and badge colour", initial_value: extras },
332
+ { type: "text_input", action_id: "wall", label: "Wall text", initial_value: current?.wall ?? "", multiline: true, condition: more },
333
+ { type: "text_input", action_id: "join_url", label: "Join link", placeholder: "/join/", initial_value: current?.joinUrl ?? "", condition: more },
334
+ { type: "text_input", action_id: "products", label: "Shopify product handles that lead to it (for plan cards)", placeholder: "gold-membership", initial_value: current?.products?.join(", ") ?? "", condition: more },
335
+ { type: "text_input", action_id: "home", label: "Members' home after sign-in", placeholder: "/members/gold/", initial_value: current?.home ?? "", condition: more },
336
+ { type: "text_input", action_id: "badge", label: "Badge colour", placeholder: "#c9a227", initial_value: current?.badge ?? "", condition: more },
249
337
  ],
250
338
  submit: { label: current ? "Save level" : "Add level", action_id: "level_save" },
251
339
  },
252
- ...(current ? [{ type: "actions", elements: [{ type: "button", action_id: "level_cancel", label: "Cancel", style: "secondary" }] }] : []),
253
340
  ];
254
341
  }
255
342
 
@@ -259,23 +346,34 @@ function siteTab(env: AccessEnv): unknown[] {
259
346
  const site = (config.rules ?? []).find((r) => r.collection === "*" && !r.taxonomy && !r.term);
260
347
  const meter = config.meter;
261
348
  return [
262
- { type: "context", text: "What applies when nothing more specific does, how rules combine, the meter and the join link." },
263
- ...sourceLine(env, "rules", "Rules"),
349
+ { type: "header", text: "Site default" },
350
+ { type: "context", text: "Who can read when no content type, term or entry rule says otherwise." },
264
351
  {
265
352
  type: "form",
266
353
  block_id: "site",
267
354
  fields: [
268
355
  ...ruleFields(levels, site, "Free to everyone", false),
269
- { type: "select", action_id: "combine", label: "Several term rules on one entry", options: [{ label: "Pass every one", value: "all" }, { label: "Pass any one", value: "any" }], initial_value: config.combine ?? "all" },
270
- { type: "number_input", action_id: "meter_limit", label: "Meter: free locked reads per period (0 = off)", min: 0, max: 1000, initial_value: meter?.limit ?? 0 },
271
- { type: "select", action_id: "meter_period", label: "Meter period", options: [{ label: "Month", value: "month" }, { label: "Week", value: "week" }], initial_value: meter?.period ?? "month" },
356
+ { type: "select", action_id: "combine", label: "When an entry has several term rules", options: [{ label: "Readers pass every one", value: "all" }, { label: "Readers pass any one", value: "any" }], initial_value: config.combine ?? "all" },
357
+ ],
358
+ submit: { label: "Save site default", action_id: "site_save" },
359
+ },
360
+ ...sourceLine(env, "rules", "Rules"),
361
+ { type: "divider" },
362
+ { type: "header", text: "Meter and join link" },
363
+ { type: "context", text: "The meter lets signed-in readers open a few locked entries each period. The join link is where walls send readers without a level." },
364
+ {
365
+ type: "form",
366
+ block_id: "site:meter",
367
+ fields: [
368
+ { type: "number_input", action_id: "meter_limit", label: "Free locked reads per period (0 = off)", min: 0, max: 1000, initial_value: meter?.limit ?? 0 },
369
+ { type: "select", action_id: "meter_period", label: "Period", options: [{ label: "Month", value: "month" }, { label: "Week", value: "week" }], initial_value: meter?.period ?? "month" },
272
370
  ...(env.collections.length
273
371
  ? [{ type: "checkbox", action_id: "meter_collections", label: "Meter covers (none ticked = all)", options: env.collections.map((c) => ({ label: c.label, value: c.slug })), initial_value: meter?.collections ?? [] }]
274
372
  : []),
275
373
  { type: "text_input", action_id: "join_href", label: "Join link", placeholder: "/join/", initial_value: config.join?.href ?? "" },
276
374
  { type: "text_input", action_id: "join_label", label: "Join link text", placeholder: "Become a member", initial_value: config.join?.label ?? "" },
277
375
  ],
278
- submit: { label: "Save site rules", action_id: "site_save" },
376
+ submit: { label: "Save meter and join link", action_id: "site_save" },
279
377
  },
280
378
  ...sourceLine(env, "meter", "Meter"),
281
379
  ...sourceLine(env, "join", "Join link"),
@@ -288,8 +386,8 @@ function typesTab(env: AccessEnv): unknown[] {
288
386
  const rules = config.rules ?? [];
289
387
  if (env.collections.length === 0) return [{ type: "empty", title: "No content types yet", size: "sm" }];
290
388
  return [
389
+ { type: "header", text: "Content types" },
291
390
  { type: "context", text: "Who can read each content type. New content types appear here as soon as they're made. Term rules (Terms tab) and an entry's own rule (its Access panel) beat these." },
292
- ...sourceLine(env, "rules", "Rules"),
293
391
  ...env.collections.map((c) => {
294
392
  const rule = rules.find((r) => r.collection === c.slug && !r.taxonomy && !r.term);
295
393
  const teaser = config.teaser?.[c.slug] ?? 0;
@@ -354,16 +452,23 @@ async function taxonomiesTab(env: AccessEnv): Promise<unknown[]> {
354
452
  }
355
453
  : { type: "context", text: "No rules: its terms don't change who can read." },
356
454
  terms.length
357
- ? {
358
- type: "form",
359
- block_id: `term:${t.name}`,
360
- fields: [
361
- { type: "combobox", action_id: "term", label: "Term", options: terms.map((x) => ({ label: x.label, value: x.slug })) },
362
- { type: "select", action_id: "scope", label: "In", options: [{ label: "Every content type", value: "*" }, ...t.collections.map((c) => ({ label: label(c), value: c }))], initial_value: "*" },
363
- ...ruleFields(levels, undefined, null),
455
+ ? modal(
456
+ `term:${t.name}`,
457
+ `Add a ${t.label} rule`,
458
+ [
459
+ {
460
+ type: "form",
461
+ block_id: `term:${t.name}`,
462
+ fields: [
463
+ { type: "combobox", action_id: "term", label: "Term", options: terms.map((x) => ({ label: x.label, value: x.slug })) },
464
+ { type: "select", action_id: "scope", label: "In", options: [{ label: "Every content type", value: "*" }, ...t.collections.map((c) => ({ label: label(c), value: c }))], initial_value: "*" },
465
+ ...ruleFields(levels, undefined, null),
466
+ ],
467
+ submit: { label: "Save term rule", action_id: "term_save" },
468
+ },
364
469
  ],
365
- submit: { label: "Save term rule", action_id: "term_save" },
366
- }
470
+ { label: "Add a term rule", style: "secondary", description: "Saving a rule for a term that has one replaces it." },
471
+ )
367
472
  : { type: "context", text: "Add terms to this taxonomy to give them rules." },
368
473
  ],
369
474
  });
@@ -377,8 +482,8 @@ function featuresTab(env: AccessEnv, editing?: string): unknown[] {
377
482
  const features = config.features ?? [];
378
483
  const current = editing ? features.find((f) => f.slug === editing) : undefined;
379
484
  return [
380
- { type: "context", text: "Perks members hold through a level or a Shopify tag, such as ad-free reading. Pages check them with <Feature>." },
381
- ...sourceLine(env, "features", "Features"),
485
+ { type: "context", text: "A level says what a member is and decides what they can read. A feature says what they get, such as ad-free reading: a switch the site's code checks. Give a feature to levels (the usual way) or to a Shopify tag for a one-off perk." },
486
+ modal("feature:new", "Add a feature", featureForm(levels), { label: "Add a feature" }),
382
487
  features.length
383
488
  ? {
384
489
  type: "table",
@@ -398,8 +503,15 @@ function featuresTab(env: AccessEnv, editing?: string): unknown[] {
398
503
  act: { type: "menu", action_id: "feature_menu", label: "Edit", items: [{ label: "Edit", value: `edit:${f.slug}` }, { label: "Remove", value: `remove:${f.slug}` }] },
399
504
  })),
400
505
  }
401
- : { type: "context", text: "No features." },
402
- { type: "header", text: current ? `Edit ${current.name}` : "Add a feature" },
506
+ : { type: "empty", title: "No features yet", description: "Add one, such as Ad-free for the Members level.", size: "sm" },
507
+ inSite("feature", features[0]?.slug ?? "ad-free"),
508
+ ...(current ? [modal(`feature:${current.slug}`, `Edit ${current.name}`, [...featureForm(levels, current), inSite("feature", current.slug, current.name)], { open: true })] : []),
509
+ ...sourceLine(env, "features", "Features"),
510
+ ];
511
+ }
512
+
513
+ function featureForm(levels: LevelConfig[], current?: FeatureConfig): unknown[] {
514
+ return [
403
515
  {
404
516
  type: "form",
405
517
  block_id: current ? `feature:${current.slug}` : "feature:new",
@@ -407,13 +519,26 @@ function featuresTab(env: AccessEnv, editing?: string): unknown[] {
407
519
  { type: "text_input", action_id: "name", label: "Name", placeholder: "Ad-free", initial_value: current?.name ?? "" },
408
520
  ...(current ? [] : [{ type: "text_input", action_id: "slug", label: "Slug (optional, fixed once saved)", placeholder: "ad-free" }]),
409
521
  ...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Levels that hold it", options: levelOptions(levels), initial_value: current?.levels ?? [] }] : []),
410
- { type: "text_input", action_id: "tags", label: "Shopify tags, comma separated (optional)", initial_value: current?.tags?.join(", ") ?? "" },
522
+ { type: "text_input", action_id: "tags", label: "Shopify tags that hold it (comma separated, optional)", initial_value: current?.tags?.join(", ") ?? "" },
411
523
  ],
412
524
  submit: { label: current ? "Save feature" : "Add feature", action_id: "feature_save" },
413
525
  },
414
526
  ];
415
527
  }
416
528
 
529
+ const FILE_FORM = (levels: LevelConfig[], features: FeatureConfig[]) => ({
530
+ type: "form",
531
+ block_id: "file",
532
+ fields: [
533
+ { type: "text_input", action_id: "folder", label: "Media folder name", placeholder: "Member downloads" },
534
+ { type: "toggle", action_id: "members", label: "Signed-in members", initial_value: true },
535
+ ...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Only these levels (none = any member)", options: levelOptions(levels), condition: { field: "members", eq: true } }] : []),
536
+ ...(features.length ? [{ type: "checkbox", action_id: "features", label: "Members with these features", options: features.map((f) => ({ label: f.name, value: f.slug })) }] : []),
537
+ { type: "toggle", action_id: "entries", label: "Readers of an entry that uses the file", initial_value: false },
538
+ ],
539
+ submit: { label: "Save folder", action_id: "file_save" },
540
+ });
541
+
417
542
  function filesTab(env: AccessEnv): unknown[] {
418
543
  const config = effective(env);
419
544
  const levels = config.levels ?? [];
@@ -421,7 +546,7 @@ function filesTab(env: AccessEnv): unknown[] {
421
546
  const files = config.files ?? [];
422
547
  return [
423
548
  { type: "context", text: "Media folders whose files only some readers may download. Any of the ways in is enough." },
424
- ...sourceLine(env, "files", "Protected files"),
549
+ modal("file", "Protect a folder", [FILE_FORM(levels, features)], { label: "Protect a folder" }),
425
550
  files.length
426
551
  ? {
427
552
  type: "table",
@@ -442,30 +567,21 @@ function filesTab(env: AccessEnv): unknown[] {
442
567
  })),
443
568
  }
444
569
  : { type: "context", text: "No protected folders: every file is public." },
445
- { type: "header", text: "Protect a folder" },
446
- {
447
- type: "form",
448
- block_id: "file",
449
- fields: [
450
- { type: "text_input", action_id: "folder", label: "Media folder name", placeholder: "Member downloads" },
451
- { type: "toggle", action_id: "members", label: "Signed-in members", initial_value: true },
452
- ...(levels.length ? [{ type: "checkbox", action_id: "levels", label: "Only these levels (none = any member)", options: levelOptions(levels), condition: { field: "members", eq: true } }] : []),
453
- ...(features.length ? [{ type: "checkbox", action_id: "features", label: "Features", options: features.map((f) => ({ label: f.name, value: f.slug })) }] : []),
454
- { type: "toggle", action_id: "entries", label: "Readers of an entry that uses it", initial_value: false },
455
- ],
456
- submit: { label: "Save folder", action_id: "file_save" },
457
- },
570
+ ...sourceLine(env, "files", "Protected files"),
458
571
  ];
459
572
  }
460
573
 
461
- /** The Access page, opened on `tab`. */
462
- export async function accessPage(env: AccessEnv, tab: Tab = "levels", toast?: Toast, edit?: { level?: string; feature?: string }): Promise<Blocks> {
574
+ /** The Access page, opened on `tab`. `test` holds the Test tab's last answer. */
575
+ export async function accessPage(env: AccessEnv, tab: Tab = "map", toast?: Toast, edit?: { level?: string; feature?: string }, test?: Record<string, unknown>): Promise<Blocks> {
576
+ const tested = (await accessTest(effective(env), test)).blocks.filter((b) => (b as { type: string }).type !== "header");
463
577
  const panels = [
464
- // Four short tabs so they fit across a phone without scrolling the page.
465
- { label: "Levels", blocks: [...levelsTab(env, edit?.level), { type: "divider" }, { type: "header", text: "Features" }, ...featuresTab(env, edit?.feature)] },
466
- { label: "Rules", blocks: [{ type: "header", text: "Site default" }, ...siteTab(env), { type: "divider" }, { type: "header", text: "Content types" }, ...typesTab(env)] },
578
+ { label: "Map", blocks: await mapTab(env) },
579
+ { label: "Levels", blocks: levelsTab(env, edit?.level) },
580
+ { label: "Features", blocks: featuresTab(env, edit?.feature) },
581
+ { label: "Rules", blocks: [...siteTab(env), { type: "divider" }, ...typesTab(env)] },
467
582
  { label: "Terms", blocks: await taxonomiesTab(env) },
468
583
  { label: "Files", blocks: filesTab(env) },
584
+ { label: "Test", blocks: tested },
469
585
  ];
470
586
  return {
471
587
  toast,
@@ -511,7 +627,7 @@ export interface Interaction {
511
627
  values?: Record<string, unknown>;
512
628
  }
513
629
 
514
- const ACCESS_ACTIONS = new Set(["reset", "level_menu", "level_cancel", "level_save", "site_save", "type_save", "term_save", "term_remove", "feature_menu", "feature_save", "file_save", "file_remove"]);
630
+ const ACCESS_ACTIONS = new Set(["map_go", "access_test", "reset", "level_menu", "level_cancel", "level_save", "site_save", "type_save", "term_save", "term_remove", "feature_menu", "feature_save", "file_save", "file_remove"]);
515
631
 
516
632
  export function isAccessAction(id: string | undefined): boolean {
517
633
  return Boolean(id && ACCESS_ACTIONS.has(id));
@@ -525,6 +641,18 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
525
641
  const target = String(i.block_id ?? "").split(":").slice(1).join(":");
526
642
 
527
643
  switch (i.action_id) {
644
+ case "map_go": {
645
+ // From the map to where that part is changed.
646
+ const go = JSON.parse(String(i.value ?? "{}")) as MapGo;
647
+ if ("level" in go) return accessPage(env, "levels", undefined, { level: go.level });
648
+ if ("feature" in go) return accessPage(env, "features", undefined, { feature: go.feature });
649
+ if ("tab" in go && (TABS as readonly string[]).includes(go.tab)) return accessPage(env, go.tab);
650
+ return accessPage(env);
651
+ }
652
+
653
+ case "access_test":
654
+ return accessPage(env, "test", undefined, undefined, values);
655
+
528
656
  case "reset": {
529
657
  const key = String(i.value ?? "") as ManagedKey;
530
658
  if (!MANAGED.includes(key)) return accessPage(env);
@@ -600,8 +728,12 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
600
728
  }
601
729
 
602
730
  case "site_save": {
731
+ // Two forms save here: the site default ("who") and the meter with
732
+ // the join link ("meter_limit"). Each changes only its own settings.
603
733
  const now = effective(env);
604
- const rule = ruleFrom(values, { collection: "*" }, now.levels ?? []);
734
+ const ruleForm = "who" in values;
735
+ const meterForm = "meter_limit" in values;
736
+ const rule = ruleForm ? ruleFrom(values, { collection: "*" }, now.levels ?? []) : null;
605
737
  if (rule && "error" in rule) return bad("site", rule.error);
606
738
  const limit = Math.max(0, Math.floor(Number(values.meter_limit) || 0));
607
739
  const known = new Set(env.collections.map((c) => c.slug));
@@ -610,17 +742,20 @@ export async function accessAction(env: AccessEnv, i: Interaction): Promise<Bloc
610
742
  const href = String(values.join_href ?? "").trim();
611
743
  const label = String(values.join_label ?? "").trim();
612
744
  await saveSite(env, (site, n) => {
613
- const rules = start(site, n, "rules").filter((r) => !(r.collection === "*" && !r.taxonomy && !r.term));
614
- if (rule) rules.push(rule);
615
- return {
616
- ...site,
617
- rules,
618
- combine: values.combine === "any" ? "any" : "all",
619
- meter,
620
- join: href ? { href, ...(label ? { label } : {}) } : null,
621
- };
745
+ const out: SiteAccess = { ...site };
746
+ if (ruleForm) {
747
+ const rules = start(site, n, "rules").filter((r) => !(r.collection === "*" && !r.taxonomy && !r.term));
748
+ if (rule) rules.push(rule);
749
+ out.rules = rules;
750
+ out.combine = values.combine === "any" ? "any" : "all";
751
+ }
752
+ if (meterForm) {
753
+ out.meter = meter;
754
+ out.join = href ? { href, ...(label ? { label } : {}) } : null;
755
+ }
756
+ return out;
622
757
  });
623
- return ok("site", "Site rules saved.");
758
+ return ok("site", ruleForm && !meterForm ? "Site default saved." : meterForm && !ruleForm ? "Meter and join link saved." : "Site rules saved.");
624
759
  }
625
760
 
626
761
  case "type_save": {
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The access map: for each kind of reader (visitors, any signed-in member,
3
+ * each level) what the site's settings give them, and through what. It reads
4
+ * only the settings (levels, features, rules, entry rules, folders), so it
5
+ * says which rules name a level, not which rule wins on a given post; the
6
+ * Test tab answers that.
7
+ */
8
+
9
+ import { closure } from "./access.js";
10
+ import type { FeatureConfig, LevelConfig, RedeemIDConfig } from "./config.js";
11
+ import type { EntryRule } from "./settings.js";
12
+
13
+ /** Where staff change an item: a tab on the Access page, a level or feature dialog, or an entry's editor. */
14
+ export type MapGo = { tab: "levels" | "features" | "site" | "types" | "taxonomies" | "files" } | { level: string } | { feature: string } | { entry: { collection: string; id: string } };
15
+
16
+ export type MapKind = "feature" | "content" | "term" | "entry" | "folder";
17
+
18
+ export interface MapItem {
19
+ kind: MapKind;
20
+ label: string;
21
+ /** Extra fact, such as "drip 7 days" or "any content type". */
22
+ note?: string;
23
+ /** The level, feature or "Signed in" it comes through, when not held directly. */
24
+ via?: string;
25
+ go: MapGo;
26
+ }
27
+
28
+ export interface MapRow {
29
+ /** "everyone", "signed-in" or the level's slug. */
30
+ id: string;
31
+ name: string;
32
+ /** Who is in it: tags, or a sentence for the two built-in rows. */
33
+ who: string;
34
+ /** Members holding it now (levels only, when counted). */
35
+ members?: number;
36
+ /** Levels it includes, by name. */
37
+ includes: string[];
38
+ /** Ids of every row this one also gets (itself not included). */
39
+ chain: string[];
40
+ items: MapItem[];
41
+ }
42
+
43
+ export interface AccessMapInput {
44
+ config: RedeemIDConfig;
45
+ entries: EntryRule[];
46
+ collections: Array<{ slug: string; label: string }>;
47
+ taxonomies: Array<{ name: string; label: string }>;
48
+ /** Tag words for a level, as the Levels table shows them. */
49
+ tagWords(level: LevelConfig): string;
50
+ /** Members holding each level (including through includes). */
51
+ holders?: Record<string, number>;
52
+ }
53
+
54
+ /** Build the map. Levels come out with the ones others include first. */
55
+ export function accessMap(input: AccessMapInput): MapRow[] {
56
+ const { config, entries } = input;
57
+ const levels = config.levels ?? [];
58
+ const features = config.features ?? [];
59
+ const rules = config.rules ?? [];
60
+ const files = config.files ?? [];
61
+ const typeName = (slug: string) => (slug === "*" ? "any content type" : (input.collections.find((c) => c.slug === slug)?.label ?? slug));
62
+ const taxName = (name: string) => input.taxonomies.find((t) => t.name === name)?.label ?? name;
63
+ const levelName = (slug: string) => levels.find((l) => l.slug === slug)?.name ?? slug;
64
+
65
+ // What names each reader directly: "everyone", "signed-in" or a level slug.
66
+ const direct = new Map<string, MapItem[]>();
67
+ const add = (who: string, item: MapItem) => direct.set(who, [...(direct.get(who) ?? []), item]);
68
+ const whoOf = (open: boolean | undefined, list: string[]) => (open ? ["everyone"] : list.length ? list : ["signed-in"]);
69
+
70
+ for (const r of rules) {
71
+ const drip = r.drip ? `drip ${r.drip} days` : undefined;
72
+ if (r.taxonomy && r.term) {
73
+ const item: MapItem = { kind: "term", label: `${taxName(r.taxonomy)}: ${r.term}`, note: [typeName(r.collection), drip].filter(Boolean).join(", "), go: { tab: "taxonomies" } };
74
+ for (const who of whoOf(r.open, r.levels)) add(who, item);
75
+ } else if (r.collection === "*") {
76
+ for (const who of whoOf(r.open, r.levels)) add(who, { kind: "content", label: "Site default", note: ["everything without its own rule", drip].filter(Boolean).join(", "), go: { tab: "site" } });
77
+ } else {
78
+ for (const who of whoOf(r.open, r.levels)) add(who, { kind: "content", label: typeName(r.collection), note: drip, go: { tab: "types" } });
79
+ }
80
+ }
81
+ for (const e of entries) {
82
+ if (e.mode === "inherit") continue;
83
+ const note = [typeName(e.collection), e.mode === "add" ? "on top of the site's rules" : "", e.drip ? `drip ${e.drip} days` : ""].filter(Boolean).join(", ");
84
+ const item: MapItem = { kind: "entry", label: e.slug || e.entryId, note, go: { entry: { collection: e.collection, id: e.entryId } } };
85
+ for (const who of whoOf(e.mode === "open", e.levels)) add(who, item);
86
+ }
87
+ const featureFolders = new Map<string, MapItem[]>();
88
+ for (const f of files) {
89
+ const item: MapItem = { kind: "folder", label: f.folder, note: f.entries ? "also readers of entries that use its files" : undefined, go: { tab: "files" } };
90
+ if (f.levels) for (const who of f.levels.length ? f.levels : ["signed-in"]) add(who, item);
91
+ for (const slug of f.features ?? []) featureFolders.set(slug, [...(featureFolders.get(slug) ?? []), item]);
92
+ }
93
+ const featureItem = (f: FeatureConfig): MapItem => ({ kind: "feature", label: f.name, note: f.tags?.length ? `also tag ${f.tags.join(", ")}` : undefined, go: { feature: f.slug } });
94
+ for (const f of features) for (const slug of f.levels ?? []) add(slug, featureItem(f));
95
+
96
+ // Everything a reader gets: its own items, then each included level's (marked "via").
97
+ const gather = (ids: Array<{ id: string; via?: string }>): MapItem[] => {
98
+ const out: MapItem[] = [];
99
+ const seen = new Set<string>();
100
+ const push = (item: MapItem, via?: string) => {
101
+ const key = `${item.kind}:${item.label}:${item.note ?? ""}`;
102
+ if (seen.has(key)) return;
103
+ seen.add(key);
104
+ out.push(via ? { ...item, via } : item);
105
+ };
106
+ for (const { id, via } of ids) {
107
+ for (const item of direct.get(id) ?? []) {
108
+ push(item, via);
109
+ // A folder opened to a feature this reader holds.
110
+ if (item.kind === "feature") {
111
+ const slug = (item.go as { feature: string }).feature;
112
+ for (const folder of featureFolders.get(slug) ?? []) push(folder, via ?? item.label);
113
+ }
114
+ }
115
+ }
116
+ return out;
117
+ };
118
+
119
+ const ordered = [...levels].sort((a, b) => closure(a.slug, levels).length - closure(b.slug, levels).length);
120
+ const rows: MapRow[] = [
121
+ { id: "everyone", name: "Everyone", who: "Visitors and members alike", includes: [], chain: [], items: gather([{ id: "everyone" }]) },
122
+ { id: "signed-in", name: "Signed in", who: "Any member who signs in, whatever their tags", includes: [], chain: ["everyone"], items: gather([{ id: "signed-in" }, { id: "everyone", via: "Everyone" }]) },
123
+ ];
124
+ for (const l of ordered) {
125
+ const chain = closure(l.slug, levels);
126
+ rows.push({
127
+ id: l.slug,
128
+ name: l.name,
129
+ who: input.tagWords(l),
130
+ members: input.holders?.[l.slug],
131
+ includes: (l.includes ?? []).map(levelName),
132
+ chain: [...chain.slice(1), "signed-in", "everyone"],
133
+ items: gather([...chain.map((slug, i) => ({ id: slug, via: i ? levelName(slug) : undefined })), { id: "signed-in", via: "Signed in" }, { id: "everyone", via: "Everyone" }]),
134
+ });
135
+ }
136
+ return rows;
137
+ }
138
+
139
+ /** How many items of one kind a row holds, and how many of those are its own. */
140
+ export function tally(row: MapRow, kind: MapKind): { all: number; own: number } {
141
+ const of = row.items.filter((i) => i.kind === kind);
142
+ return { all: of.length, own: of.filter((i) => !i.via).length };
143
+ }