@karibsen/eponyme 0.2.0 → 0.4.0

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.
Files changed (35) hide show
  1. package/README.md +81 -5
  2. package/dist/eponyme.mjs +2 -1
  3. package/dist/module.d.mts +18 -0
  4. package/dist/module.json +1 -1
  5. package/dist/module.mjs +15 -1
  6. package/dist/runtime/composables/useEponyme.js +2 -1
  7. package/dist/runtime/composables/useEponymeCollection.js +7 -2
  8. package/dist/runtime/composables/useEponymeForm.d.ts +4 -0
  9. package/dist/runtime/composables/useEponymeForm.js +8 -1
  10. package/dist/runtime/server/api/eponyme/[name].get.js +6 -6
  11. package/dist/runtime/server/api/eponyme-collections/[path].get.js +2 -0
  12. package/dist/runtime/server/api/eponyme-forms/[path].post.js +15 -0
  13. package/dist/runtime/server/api/eponyme-sitemap.get.js +3 -1
  14. package/dist/runtime/server/middleware/eponyme-no-store.d.ts +14 -0
  15. package/dist/runtime/server/middleware/eponyme-no-store.js +6 -0
  16. package/dist/runtime/server/services/eponyme-form-store.d.ts +0 -1
  17. package/dist/runtime/server/services/eponyme-form-store.js +2 -1
  18. package/dist/runtime/server/services/eponyme-service.js +8 -1
  19. package/dist/runtime/server/services/eponyme-store.d.ts +43 -3
  20. package/dist/runtime/server/services/eponyme-store.js +114 -40
  21. package/dist/runtime/server/utils/eponyme-cache.d.ts +16 -0
  22. package/dist/runtime/server/utils/eponyme-cache.js +20 -0
  23. package/dist/runtime/server/utils/eponyme-captcha.d.ts +6 -0
  24. package/dist/runtime/server/utils/eponyme-captcha.js +14 -0
  25. package/dist/runtime/types/captcha.d.ts +16 -0
  26. package/dist/runtime/types/captcha.js +0 -0
  27. package/dist/runtime/types/eponyme-config.d.ts +7 -0
  28. package/dist/runtime/types/eponyme.d.ts +3 -0
  29. package/dist/runtime/types/index.d.ts +1 -0
  30. package/dist/runtime/types/index.js +1 -0
  31. package/dist/runtime/utils/no-captcha.d.ts +7 -0
  32. package/dist/runtime/utils/no-captcha.js +5 -0
  33. package/dist/runtime/utils/variables.d.ts +8 -1
  34. package/dist/runtime/utils/variables.js +17 -4
  35. package/package.json +1 -1
package/README.md CHANGED
@@ -559,6 +559,49 @@ ${entries.map(entry => ` <url><loc>https://example.com${entry.loc}</loc>${entry
559
559
  })
560
560
  ```
561
561
 
562
+ ## Caching
563
+
564
+ Published content is the same for every visitor, so it is cached at three levels. The
565
+ defaults suit a content site and can be tuned or switched off:
566
+
567
+ ```ts
568
+ export default defineNuxtConfig({
569
+ eponyme: {
570
+ prismaClient: '~~/server/utils/prisma',
571
+ cacheSeconds: 5, // server instance
572
+ browserCacheSeconds: 30, // visitor's browser
573
+ cdnCacheSeconds: 300, // CDN or edge
574
+ },
575
+ })
576
+ ```
577
+
578
+ | Option | Who holds the copy | Purgeable |
579
+ |---|---|---|
580
+ | `cacheSeconds` | The server instance, in memory | Yes — cleared on every write |
581
+ | `browserCacheSeconds` | The visitor's browser | **No** |
582
+ | `cdnCacheSeconds` | The CDN | Yes — see below |
583
+
584
+ `browserCacheSeconds` is what makes a client-side navigation instant: the browser answers
585
+ from its own cache instead of crossing the network. It is also the one window nobody can
586
+ shorten. A visitor holding a copy keeps it until it expires, publication or not, which is
587
+ why the default is deliberately small. Set it to `0` if a publication must be visible
588
+ immediately to everyone; navigation then costs a round trip again.
589
+
590
+ The CDN window can be long because it can be purged, and `eponyme:entry:published` is the
591
+ place to do it:
592
+
593
+ ```ts
594
+ nitroApp.hooks.hook('eponyme:entry:published', async ({ name, collection }) => {
595
+ await purgeCdnCache(collection ? `/${collection.name}/${collection.slug}` : `/${name}`)
596
+ })
597
+ ```
598
+
599
+ **Only published content is ever cached.** Drafts, historical versions, `raw=1` reads,
600
+ submissions, the trash, the user list and the session route all answer `no-store`. That is
601
+ enforced by a server middleware that marks every Eponyme route uncacheable, which the few
602
+ public ones override — so a route added later is private until someone decides otherwise,
603
+ rather than public until someone remembers.
604
+
562
605
  ## Forms
563
606
 
564
607
  Use `form()` for public forms. A form owns a schema, its own routes and its anti-abuse
@@ -668,10 +711,43 @@ Two protections apply to the public submission route:
668
711
  - a **body size limit**, 64 KB by default, configurable with `maxBodyBytes`. An oversized
669
712
  body is refused with `413` before it is parsed.
670
713
 
671
- A captcha is not part of the module. A public form is a normal POST route, so adding
672
- Turnstile, hCaptcha or anything else is done in the host application: render the widget
673
- on the page, send its token, and verify it in your own handler before calling
674
- `validateEponymeForm()`.
714
+ ### Captcha
715
+
716
+ Set `captcha: true` on the form, then install an adapter — Eponyme defines the contract
717
+ but ships no provider:
718
+
719
+ ```bash
720
+ pnpm add @karibsen/eponyme-captcha
721
+ ```
722
+
723
+ ```ts
724
+ export default defineNuxtConfig({
725
+ modules: ['@karibsen/eponyme', '@karibsen/eponyme-captcha'],
726
+ eponymeCaptcha: { siteKey: '<your-turnstile-site-key>' },
727
+ })
728
+ ```
729
+
730
+ Render the widget and bind its token; `useEponymeForm` sends it alongside the fields and
731
+ the server verifies it before validating anything:
732
+
733
+ ```vue
734
+ <script setup lang="ts">
735
+ const { captchaToken, requiresCaptcha, submit } = useEponymeForm('contact')
736
+ </script>
737
+
738
+ <template>
739
+ <NuxtTurnstile v-if="requiresCaptcha" v-model="captchaToken" />
740
+ </template>
741
+ ```
742
+
743
+ **It fails closed.** Without an adapter, a form declaring `captcha: true` answers `500`
744
+ rather than accepting submissions unprotected. A rejected token answers `422` with a
745
+ `_form` error; the provider's reason stays in the server log, since telling a bot which
746
+ check it failed only helps it.
747
+
748
+ Writing an adapter for another provider means supplying a `name` and an
749
+ `async verify(token, { ip })` returning `{ success, reason? }`, then pointing the
750
+ `#eponyme/captcha` alias at it.
675
751
 
676
752
  Rate limiting and file uploads are not implemented yet.
677
753
 
@@ -730,7 +806,7 @@ a trash move leaves the entry itself untouched.
730
806
 
731
807
  ## Current status
732
808
 
733
- Eponyme is at version `0.1.1`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
809
+ Eponyme is at version `0.4.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
734
810
 
735
811
  - Client revision tokens for long-running concurrent edits: the current `updatedAt` check protects overlapping writes within one request, not two editors who loaded the same older revision. A deletion is not guarded by a revision either
736
812
  - Retention controls for the trash: entries stay there until someone empties it
package/dist/eponyme.mjs CHANGED
@@ -31,7 +31,8 @@ function form(options) {
31
31
  // Storing a submission has to be an explicit decision, so `custom` is the default.
32
32
  submission: { mode: options.submission?.mode ?? "custom" },
33
33
  honeypot,
34
- maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES
34
+ maxBodyBytes: options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES,
35
+ captcha: options.captcha ?? false
35
36
  };
36
37
  }
37
38
 
package/dist/module.d.mts CHANGED
@@ -20,6 +20,24 @@ interface ModuleOptions {
20
20
  /** Fixed lifetime of an authenticated session. */
21
21
  sessionDurationDays?: number;
22
22
  };
23
+ /**
24
+ * How long a server instance may reuse content it has already read, in seconds.
25
+ * A save clears what it changed on its own instance, so this only bounds how long
26
+ * another instance can still serve the previous content. `0` disables the cache.
27
+ */
28
+ cacheSeconds?: number;
29
+ /**
30
+ * How long a browser may reuse published content, in seconds. This is what makes a
31
+ * client-side navigation instant, and it is the one window nobody can purge: a visitor
32
+ * who already holds a copy keeps it until it expires. Keep it short. `0` disables it.
33
+ */
34
+ browserCacheSeconds?: number;
35
+ /**
36
+ * How long a CDN may reuse published content, in seconds. A CDN can be purged — the
37
+ * `eponyme:entry:published` hook is the place to do it — so this can be generous.
38
+ * `0` disables it.
39
+ */
40
+ cdnCacheSeconds?: number;
23
41
  }
24
42
  declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
25
43
 
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.2.0",
4
+ "version": "0.4.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -15,7 +15,10 @@ const module$1 = defineNuxtModule({
15
15
  colorPresets: [],
16
16
  auth: {
17
17
  sessionDurationDays: 7
18
- }
18
+ },
19
+ cacheSeconds: 5,
20
+ browserCacheSeconds: 30,
21
+ cdnCacheSeconds: 300
19
22
  },
20
23
  moduleDependencies: {
21
24
  "@nuxt/icon": {
@@ -70,6 +73,11 @@ const module$1 = defineNuxtModule({
70
73
  nuxt.options.runtimeConfig.eponymeAuth = {
71
74
  sessionDurationDays: options.auth?.sessionDurationDays ?? 7
72
75
  };
76
+ nuxt.options.runtimeConfig.eponymeContent = {
77
+ cacheSeconds: options.cacheSeconds ?? 5,
78
+ browserCacheSeconds: options.browserCacheSeconds ?? 30,
79
+ cdnCacheSeconds: options.cdnCacheSeconds ?? 300
80
+ };
73
81
  const preparing = Boolean(nuxt.options._prepare);
74
82
  if (!options.prismaClient) {
75
83
  if (!preparing) throw new Error(`${pc.red("[Eponyme]")} eponyme.prismaClient is required.`);
@@ -130,6 +138,7 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
130
138
  nuxt.options.alias["#eponyme/prisma"] = prismaPath;
131
139
  const variablesPath = await findPath(resolve(nuxt.options.rootDir, "eponyme.variables.ts"));
132
140
  nuxt.options.alias["#eponyme/variables"] = variablesPath ?? resolver.resolve("./runtime/utils/empty-variables");
141
+ nuxt.options.alias["#eponyme/captcha"] ??= resolver.resolve("./runtime/utils/no-captcha");
133
142
  addTypeTemplate({
134
143
  filename: "types/eponyme-config.d.ts",
135
144
  getContents: () => `declare module '#eponyme/config' {
@@ -140,8 +149,13 @@ declare module '#eponyme/variables' {
140
149
  const variables: typeof import(${JSON.stringify(nuxt.options.alias["#eponyme/variables"])})['default']
141
150
  export default variables
142
151
  }
152
+ declare module '#eponyme/captcha' {
153
+ const verifier: import('${resolver.resolve("./runtime/types/captcha")}').EponymeCaptchaVerifier
154
+ export default verifier
155
+ }
143
156
  `
144
157
  });
158
+ addServerHandler({ middleware: true, handler: resolver.resolve("./runtime/server/middleware/eponyme-no-store") });
145
159
  addServerHandler({ route: "/api/eponyme/**", handler: resolver.resolve("./runtime/server/api/eponyme/[name].get") });
146
160
  addServerHandler({ route: "/api/eponyme/**", method: "patch", handler: resolver.resolve("./runtime/server/api/eponyme/[name].patch") });
147
161
  addServerHandler({ route: "/api/eponyme-statuses", handler: resolver.resolve("./runtime/server/api/eponyme-statuses.get") });
@@ -10,9 +10,10 @@ export function useEponyme(name, options = {}) {
10
10
  const saving = ref(false);
11
11
  const requestFetch = useRequestFetch();
12
12
  const cacheKey = `eponyme:${name}:${version}${options.raw ? ":raw" : ""}`;
13
+ const fetchCache = version === "published" && !options.raw ? void 0 : "no-store";
13
14
  const { data: response, pending: loading, error, refresh: load } = useAsyncData(
14
15
  cacheKey,
15
- () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: "no-store" }),
16
+ () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: fetchCache }),
16
17
  { getCachedData: cacheDuringHydrationOnly }
17
18
  );
18
19
  const data = computed(() => response.value?.data);
@@ -15,7 +15,8 @@ export function useEponymeCollection(name, options = {}) {
15
15
  // The options belong in the key, otherwise two differently sorted calls to the
16
16
  // same collection would share one cache entry.
17
17
  `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
18
- () => requestFetch(`/api/eponyme-collections/${name}`, { query, cache: "no-store" }),
18
+ // Always the published listing, which is publicly cacheable: the browser cache may answer it.
19
+ () => requestFetch(`/api/eponyme-collections/${name}`, { query }),
19
20
  { getCachedData: cacheDuringHydrationOnly }
20
21
  );
21
22
  const entries = computed(() => result.data.value?.entries ?? []);
@@ -37,7 +38,11 @@ export function useEponymeCollectionEntry(name, slug, options = {}) {
37
38
  const requestFetch = useRequestFetch();
38
39
  const result = useAsyncData(
39
40
  `eponyme:collection-entry:${name}:${slug}:${version}`,
40
- () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, { query: { version }, cache: "no-store" }),
41
+ () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, {
42
+ query: { version },
43
+ // A draft or a historical version must never be stored, whatever the server says.
44
+ cache: version === "published" ? void 0 : "no-store"
45
+ }),
41
46
  { getCachedData: cacheDuringHydrationOnly }
42
47
  );
43
48
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
@@ -24,6 +24,10 @@ export interface UseEponymeFormResult<Data extends Record<string, unknown>> {
24
24
  submitted: Ref<boolean>;
25
25
  /** Name of the trap field to render hidden, or `false` when disabled. */
26
26
  honeypot: string | false;
27
+ /** True when the form declares `captcha: true`; render a widget and fill `captchaToken`. */
28
+ requiresCaptcha: boolean;
29
+ /** Bind this to the captcha widget, for example `<NuxtTurnstile v-model="captchaToken" />`. */
30
+ captchaToken: Ref<string>;
27
31
  submit: () => Promise<boolean>;
28
32
  reset: () => void;
29
33
  }
@@ -10,12 +10,14 @@ function resolveForm(name) {
10
10
  if (!definition) throw new Error(`[Eponyme] Unknown form "${name}". Declare it with form() in eponyme.config.ts.`);
11
11
  return definition;
12
12
  }
13
+ const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
13
14
  export function useEponymeForm(name, options = {}) {
14
15
  const definition = resolveForm(name);
15
16
  const requestFetch = useRequestFetch();
16
17
  const serverErrors = ref({});
17
18
  const pending = ref(false);
18
19
  const submitted = ref(false);
20
+ const captchaToken = ref("");
19
21
  const values = ref(createDefaultEponymeData(definition.fields));
20
22
  const validation = useEponymeValidation(definition.fields, values, ref({}), { serverErrors });
21
23
  const fields = computed(() => Object.entries(definition.fields).map(([fieldName, fieldDefinition]) => ({
@@ -33,6 +35,7 @@ export function useEponymeForm(name, options = {}) {
33
35
  values.value = createDefaultEponymeData(definition.fields);
34
36
  serverErrors.value = {};
35
37
  submitted.value = false;
38
+ captchaToken.value = "";
36
39
  validation.reset();
37
40
  }
38
41
  async function submit() {
@@ -49,7 +52,9 @@ export function useEponymeForm(name, options = {}) {
49
52
  console.warn(`[Eponyme] Form "${name}" is managed, so its onSubmit handler is ignored.`);
50
53
  await requestFetch(`/api/eponyme-forms/${name}`, {
51
54
  method: "POST",
52
- body: values.value
55
+ // The token travels beside the fields, never as one of them: it is
56
+ // transport, and the schema only knows about declared fields.
57
+ body: definition.captcha ? { ...values.value, [CAPTCHA_TOKEN_KEY]: captchaToken.value } : values.value
53
58
  });
54
59
  }
55
60
  submitted.value = true;
@@ -72,6 +77,8 @@ export function useEponymeForm(name, options = {}) {
72
77
  pending,
73
78
  submitted,
74
79
  honeypot: definition.honeypot,
80
+ requiresCaptcha: definition.captcha,
81
+ captchaToken,
75
82
  submit,
76
83
  reset
77
84
  };
@@ -1,6 +1,7 @@
1
- import { createError, defineEventHandler, getQuery, getRequestURL, setResponseHeader } from "h3";
1
+ import { createError, defineEventHandler, getQuery, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { requireEponymeUser } from "../../utils/auth.js";
4
+ import { setEponymePublicCache } from "../../utils/eponyme-cache.js";
4
5
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
5
6
  function readVersion(raw) {
6
7
  if (raw === "draft") return "draft";
@@ -11,12 +12,11 @@ export default defineEventHandler(async (event) => {
11
12
  const name = decodeURIComponent(getRequestURL(event).pathname.replace(/^\/api\/eponyme\//, ""));
12
13
  const requestedVersion = getQuery(event).version;
13
14
  const version = readVersion(requestedVersion);
14
- if (version !== "published") {
15
- await requireEponymeUser(event);
16
- setResponseHeader(event, "Cache-Control", "no-store");
17
- }
15
+ const raw = Boolean(getQuery(event).raw);
16
+ if (version !== "published") await requireEponymeUser(event);
17
+ else if (!raw) setEponymePublicCache(event);
18
18
  const result = name ? await useEponymeService().getResult(name, version) : void 0;
19
19
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
20
- const data = getQuery(event).raw ? result.data : interpolateEponymeContent(result.data);
20
+ const data = raw ? result.data : interpolateEponymeContent(result.data);
21
21
  return requestedVersion ? { ...result, data } : { data };
22
22
  });
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler, getQuery, getRequestURL } from "h3";
2
2
  import { useEponymeService } from "../../services/eponyme-service.js";
3
3
  import { requireEponymeUser } from "../../utils/auth.js";
4
+ import { setEponymePublicCache } from "../../utils/eponyme-cache.js";
4
5
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
5
6
  const MAX_TAKE = 200;
6
7
  function readCount(raw, min, max) {
@@ -14,6 +15,7 @@ export default defineEventHandler(async (event) => {
14
15
  const query = getQuery(event);
15
16
  const version = query.version === "draft" ? "draft" : "published";
16
17
  if (version === "draft") await requireEponymeUser(event);
18
+ else if (!query.raw) setEponymePublicCache(event);
17
19
  if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
18
20
  const service = useEponymeService();
19
21
  const orderBy = query.orderBy === void 0 || query.orderBy === "" ? void 0 : String(query.orderBy);
@@ -1,6 +1,7 @@
1
1
  import { createError, defineEventHandler, getRequestHeader, readRawBody, setResponseHeader, setResponseStatus } from "h3";
2
2
  import { useEponymeFormService } from "../../services/eponyme-form-service.js";
3
3
  import { readEponymeFormRoute } from "../../utils/form-route.js";
4
+ import { CAPTCHA_TOKEN_KEY, isEponymeCaptchaConfigured, verifyEponymeCaptcha } from "../../utils/eponyme-captcha.js";
4
5
  import { callEponymeBlockingHook, callEponymeHook } from "../../utils/eponyme-hooks.js";
5
6
  export default defineEventHandler(async (event) => {
6
7
  const route = readEponymeFormRoute(event);
@@ -26,6 +27,20 @@ export default defineEventHandler(async (event) => {
26
27
  setResponseStatus(event, 201);
27
28
  return { submitted: true };
28
29
  }
30
+ if (definition.captcha) {
31
+ if (!isEponymeCaptchaConfigured()) {
32
+ throw createError({
33
+ statusCode: 500,
34
+ statusMessage: `Form "${route.name}" requires a captcha but no adapter is installed. Add one, such as @karibsen/eponyme-captcha.`
35
+ });
36
+ }
37
+ const token = payload && typeof payload === "object" ? payload[CAPTCHA_TOKEN_KEY] : void 0;
38
+ const result2 = await verifyEponymeCaptcha(event, token);
39
+ if (!result2.success) {
40
+ setResponseStatus(event, 422);
41
+ return { errors: { _form: ["Captcha verification failed. Please try again."] } };
42
+ }
43
+ }
29
44
  const validated = service.validate(route.name, payload);
30
45
  if (validated && "errors" in validated) {
31
46
  setResponseStatus(event, 422);
@@ -1,5 +1,7 @@
1
1
  import { defineEventHandler } from "h3";
2
+ import { setEponymePublicCache } from "../utils/eponyme-cache.js";
2
3
  import { getEponymeSitemapEntries } from "../utils/eponyme-sitemap.js";
3
- export default defineEventHandler(async () => {
4
+ export default defineEventHandler(async (event) => {
5
+ setEponymePublicCache(event);
4
6
  return { entries: await getEponymeSitemapEntries() };
5
7
  });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Every Eponyme route is uncacheable until it says otherwise.
3
+ *
4
+ * The routes that may be cached are a short, deliberate list — published content, the
5
+ * collection listings, the sitemap — and each calls `setEponymePublicCache` to override
6
+ * this. Everything else carries sessions, drafts, trashed entries, form submissions or
7
+ * the user list, and none of it may ever be stored by a browser or a CDN.
8
+ *
9
+ * Written as a default rather than as a header on each handler so that the dangerous
10
+ * direction is the one that takes effort: a route added later is private until someone
11
+ * decides otherwise, instead of public until someone remembers.
12
+ */
13
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, void>;
14
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler, getRequestURL } from "h3";
2
+ import { setEponymePrivateCache } from "../utils/eponyme-cache.js";
3
+ export default defineEventHandler((event) => {
4
+ if (!getRequestURL(event).pathname.startsWith("/api/eponyme")) return;
5
+ setEponymePrivateCache(event);
6
+ });
@@ -1,6 +1,5 @@
1
1
  import type { EponymeConfig, EponymeFormDefinitionBase } from '../../types/index.js';
2
2
  import { type ValidationErrors } from '../../utils/validate-eponyme-data.js';
3
- /** Kept here rather than imported, so the store stays free of H3 imports. */
4
3
  type DateValue = Date | string;
5
4
  export interface EponymeFormSubmission {
6
5
  id: string;
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { getEponymeForms } from "../../utils/get-eponyme-schemas.js";
3
3
  import { validateEponymeData } from "../../utils/validate-eponyme-data.js";
4
+ const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
4
5
  const DEFAULT_PER_PAGE = 25;
5
6
  const MAX_PER_PAGE = 100;
6
7
  export class EponymeFormService {
@@ -36,7 +37,7 @@ export class EponymeFormService {
36
37
  if (!payload || typeof payload !== "object" || Array.isArray(payload))
37
38
  return { errors: { _form: ["Body must be an object."] } };
38
39
  const input = payload;
39
- const { [definition.honeypot || ""]: _honeypot, ...rest } = input;
40
+ const { [definition.honeypot || ""]: _honeypot, [CAPTCHA_TOKEN_KEY]: _captcha, ...rest } = input;
40
41
  const unknownKeys = Object.keys(rest).filter((key) => !(key in definition.fields));
41
42
  if (unknownKeys.length)
42
43
  return { errors: Object.fromEntries(unknownKeys.map((key) => [key, ["Unknown field."]])) };
@@ -1,8 +1,15 @@
1
1
  import eponymeConfig from "#eponyme/config";
2
2
  import prisma from "#eponyme/prisma";
3
+ import { useRuntimeConfig } from "nitropack/runtime";
3
4
  import { EponymeService } from "./eponyme-store.js";
4
5
  export { EponymeService } from "./eponyme-store.js";
5
- const eponymeService = new EponymeService(eponymeConfig, prisma);
6
+ let eponymeService;
6
7
  export function useEponymeService() {
8
+ if (!eponymeService) {
9
+ const contentConfig = useRuntimeConfig().eponymeContent;
10
+ eponymeService = new EponymeService(eponymeConfig, prisma, {
11
+ cacheSeconds: contentConfig?.cacheSeconds ?? 5
12
+ });
13
+ }
7
14
  return eponymeService;
8
15
  }
@@ -209,17 +209,51 @@ export declare class EponymeService {
209
209
  private readonly client;
210
210
  private readonly schemas;
211
211
  private readonly collections;
212
- constructor(config: EponymeConfig, client: PrismaEponymeClient);
212
+ /** Entries a read has already persisted the normalized shape for, so it only attempts it once. */
213
+ private readonly healedByRead;
214
+ /**
215
+ * Read-path cache of normalized rows, so a page that reads the same entry from a
216
+ * singleton, a listing and a layout pays one round trip instead of one per read.
217
+ * Writers never read from it, so the `updatedAt` they lock on always comes from the
218
+ * database and a stale key can never turn into a spurious conflict.
219
+ */
220
+ private readonly cache;
221
+ private readonly cacheMs;
222
+ constructor(config: EponymeConfig, client: PrismaEponymeClient, options?: {
223
+ cacheSeconds?: number;
224
+ });
225
+ private cached;
226
+ /**
227
+ * Drops what a write to `name` invalidates: the entry itself, and the listing of the
228
+ * collection that contains it, whose row set just changed.
229
+ */
230
+ private invalidate;
213
231
  getSchema(name: string): EponymeSchema | undefined;
214
232
  getCollection(name: string): EponymeCollectionDefinitionBase | undefined;
215
233
  private getCollectionEntry;
216
- /** Reconcile every configured eponyme at application startup. */
234
+ /** Reconcile every configured eponyme at application startup. This is the one place that heals. */
217
235
  syncAll(): Promise<void>;
218
236
  private reconcile;
219
237
  private createState;
220
238
  private normalizeState;
221
- /** Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token. */
239
+ /**
240
+ * A singleton row is created on first read, but only when it is genuinely missing.
241
+ * An `upsert` would send a write on every read instead, and each one costs a database
242
+ * round trip on the critical path of a public page.
243
+ */
244
+ private loadSingletonRow;
245
+ /**
246
+ * Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token.
247
+ *
248
+ * `heal` persists the normalized state when it differs from what is stored. It has to stay on for
249
+ * writers, because healing moves `updatedAt` and so decides the lock token they are about to compare.
250
+ *
251
+ * `cache` is only ever set for published content. Draft reads serve the preview panel, which has to
252
+ * show a save immediately, and they come from the dashboard rather than from a public page — so
253
+ * there is nothing to gain by caching them and a stale preview to lose.
254
+ */
222
255
  private loadRow;
256
+ private readRow;
223
257
  private loadState;
224
258
  /**
225
259
  * Writes a new state only if the row still carries the `updatedAt` we read.
@@ -227,6 +261,12 @@ export declare class EponymeService {
227
261
  * instead of silently discarding their changes.
228
262
  */
229
263
  private writeState;
264
+ /**
265
+ * The live rows of a collection, shared by the public listing and the sitemap so both
266
+ * read them once. The trash and the export deliberately query around it: one wants the
267
+ * rows this filter excludes, the other must not see a cached view of the content.
268
+ */
269
+ private liveCollectionRows;
230
270
  get(name: string, version?: EponymeVersionSelector): Promise<Record<string, unknown> | undefined>;
231
271
  getResult(name: string, version?: EponymeVersionSelector): Promise<EponymeResult | undefined>;
232
272
  /** Content as it was in a given version, for previewing without restoring it. */
@@ -7,13 +7,44 @@ import { applyPreviewSlug } from "../../utils/preview.js";
7
7
  import { validateEponymeData, validateEponymePatch } from "../../utils/validate-eponyme-data.js";
8
8
  export const COLLECTION_METADATA_KEYS = ["updatedAt", "publishedAt", "title", "slug"];
9
9
  export class EponymeService {
10
- constructor(config, client) {
10
+ constructor(config, client, options = {}) {
11
11
  this.client = client;
12
12
  this.schemas = getEponymeSchemas(config);
13
13
  this.collections = getEponymeCollections(config);
14
+ this.cacheMs = Math.max(0, options.cacheSeconds ?? 5) * 1e3;
14
15
  }
15
16
  schemas;
16
17
  collections;
18
+ /** Entries a read has already persisted the normalized shape for, so it only attempts it once. */
19
+ healedByRead = /* @__PURE__ */ new Set();
20
+ /**
21
+ * Read-path cache of normalized rows, so a page that reads the same entry from a
22
+ * singleton, a listing and a layout pays one round trip instead of one per read.
23
+ * Writers never read from it, so the `updatedAt` they lock on always comes from the
24
+ * database and a stale key can never turn into a spurious conflict.
25
+ */
26
+ cache = /* @__PURE__ */ new Map();
27
+ cacheMs;
28
+ cached(key, load) {
29
+ if (!this.cacheMs) return load();
30
+ const hit = this.cache.get(key);
31
+ if (hit && hit.expires > Date.now()) return hit.value;
32
+ const pending = load().catch((error) => {
33
+ this.cache.delete(key);
34
+ throw error;
35
+ });
36
+ this.cache.set(key, { value: pending, expires: Date.now() + this.cacheMs });
37
+ return pending;
38
+ }
39
+ /**
40
+ * Drops what a write to `name` invalidates: the entry itself, and the listing of the
41
+ * collection that contains it, whose row set just changed.
42
+ */
43
+ invalidate(name) {
44
+ this.cache.delete(`row:${name}`);
45
+ const collection = this.getCollectionEntry(name)?.name;
46
+ if (collection) this.cache.delete(`rows:${collection}`);
47
+ }
17
48
  getSchema(name) {
18
49
  return this.schemas[name] ?? this.getCollectionEntry(name)?.definition.fields;
19
50
  }
@@ -28,9 +59,9 @@ export class EponymeService {
28
59
  if (slug && !slug.includes("/")) return { name: collectionName, slug, definition };
29
60
  }
30
61
  }
31
- /** Reconcile every configured eponyme at application startup. */
62
+ /** Reconcile every configured eponyme at application startup. This is the one place that heals. */
32
63
  async syncAll() {
33
- await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name)));
64
+ await Promise.all(Object.keys(this.schemas).map((name) => this.loadState(name, { heal: true })));
34
65
  }
35
66
  reconcile(schema, value, mode) {
36
67
  const persisted = value && typeof value === "object" && !Array.isArray(value) ? value : {};
@@ -66,20 +97,50 @@ export class EponymeService {
66
97
  }
67
98
  };
68
99
  }
69
- /** Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token. */
70
- async loadRow(name) {
100
+ /**
101
+ * A singleton row is created on first read, but only when it is genuinely missing.
102
+ * An `upsert` would send a write on every read instead, and each one costs a database
103
+ * round trip on the critical path of a public page.
104
+ */
105
+ async loadSingletonRow(name, schema) {
106
+ const row = await this.client.eponyme.findUnique({ where: { name } });
107
+ if (row) return row;
108
+ try {
109
+ return await this.client.eponyme.create({ data: { name, data: this.createState(schema) } });
110
+ } catch (error) {
111
+ if (isPrismaError(error, "P2002")) return await this.client.eponyme.findUnique({ where: { name } });
112
+ throw error;
113
+ }
114
+ }
115
+ /**
116
+ * Reads the stored state along with the `updatedAt` it was read at, used as the optimistic lock token.
117
+ *
118
+ * `heal` persists the normalized state when it differs from what is stored. It has to stay on for
119
+ * writers, because healing moves `updatedAt` and so decides the lock token they are about to compare.
120
+ *
121
+ * `cache` is only ever set for published content. Draft reads serve the preview panel, which has to
122
+ * show a save immediately, and they come from the dashboard rather than from a public page — so
123
+ * there is nothing to gain by caching them and a stale preview to lose.
124
+ */
125
+ async loadRow(name, { heal = false, cache = false } = {}) {
126
+ if (!cache || heal) return await this.readRow(name, heal);
127
+ return await this.cached(`row:${name}`, () => this.readRow(name, false));
128
+ }
129
+ async readRow(name, heal) {
71
130
  const schema = this.getSchema(name);
72
131
  if (!schema) return void 0;
73
- const defaults = this.createState(schema);
74
- const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.client.eponyme.upsert({ where: { name }, create: { name, data: defaults }, update: {} });
132
+ const row = this.getCollectionEntry(name) ? await this.client.eponyme.findUnique({ where: { name } }) : await this.loadSingletonRow(name, schema);
75
133
  if (!row || row.deletedAt) return void 0;
76
134
  const state = this.normalizeState(schema, row.data);
77
135
  if (sameData(state, row.data)) return { state, updatedAt: row.updatedAt };
136
+ if (!heal && this.healedByRead.has(name)) return { state, updatedAt: row.updatedAt };
137
+ this.healedByRead.add(name);
78
138
  const healed = await this.client.eponyme.update({ where: { name }, data: { data: state } });
139
+ this.invalidate(name);
79
140
  return { state, updatedAt: healed.updatedAt ?? row.updatedAt };
80
141
  }
81
- async loadState(name) {
82
- return (await this.loadRow(name))?.state;
142
+ async loadState(name, options = {}) {
143
+ return (await this.loadRow(name, options))?.state;
83
144
  }
84
145
  /**
85
146
  * Writes a new state only if the row still carries the `updatedAt` we read.
@@ -88,19 +149,38 @@ export class EponymeService {
88
149
  */
89
150
  async writeState(name, next, expectedUpdatedAt) {
90
151
  const data = next;
91
- if (!expectedUpdatedAt) {
92
- await this.client.eponyme.update({ where: { name }, data: { data } });
93
- return true;
152
+ try {
153
+ if (!expectedUpdatedAt) {
154
+ await this.client.eponyme.update({ where: { name }, data: { data } });
155
+ return true;
156
+ }
157
+ const { count } = await this.client.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data: { data } });
158
+ return count > 0;
159
+ } finally {
160
+ this.invalidate(name);
94
161
  }
95
- const { count } = await this.client.eponyme.updateMany({ where: { name, updatedAt: expectedUpdatedAt }, data: { data } });
96
- return count > 0;
162
+ }
163
+ /**
164
+ * The live rows of a collection, shared by the public listing and the sitemap so both
165
+ * read them once. The trash and the export deliberately query around it: one wants the
166
+ * rows this filter excludes, the other must not see a cached view of the content.
167
+ */
168
+ async liveCollectionRows(name, cache = false) {
169
+ const load = () => this.client.eponyme.findMany({
170
+ where: { name: { startsWith: `${name}/` }, deletedAt: null },
171
+ orderBy: [
172
+ { updatedAt: "desc" },
173
+ { name: "asc" }
174
+ ]
175
+ });
176
+ return cache ? await this.cached(`rows:${name}`, load) : await load();
97
177
  }
98
178
  async get(name, version = "published") {
99
179
  return (await this.getResult(name, version))?.data;
100
180
  }
101
181
  async getResult(name, version = "published") {
102
182
  if (typeof version === "number") return this.getVersionResult(name, version);
103
- const state = await this.loadState(name);
183
+ const state = await this.loadState(name, { cache: version === "published" });
104
184
  if (!state) return void 0;
105
185
  if (version === "published" && this.getCollectionEntry(name) && !state.__eponyme.publishedAt) return void 0;
106
186
  return {
@@ -138,25 +218,20 @@ export class EponymeService {
138
218
  async listCollection(name, version = "published", query = {}) {
139
219
  const definition = this.collections[name];
140
220
  if (!definition) return void 0;
141
- const rows = await this.client.eponyme.findMany({
142
- where: { name: { startsWith: `${name}/` }, deletedAt: null },
143
- orderBy: [
144
- { updatedAt: "desc" },
145
- { name: "asc" }
146
- ]
147
- });
148
- const entries = await Promise.all(rows.filter((row) => !row.name.slice(name.length + 1).includes("/")).map(async (row) => {
221
+ const rows = await this.liveCollectionRows(name, version === "published");
222
+ const entries = rows.flatMap((row) => {
149
223
  const slug = row.name.slice(name.length + 1);
150
- const result = await this.getResult(row.name, version);
151
- return {
224
+ if (!slug || slug.includes("/")) return [];
225
+ const state = this.normalizeState(definition.fields, row.data);
226
+ return [{
152
227
  slug,
153
- title: String(result?.data[definition.titleField] || slug),
154
- data: result?.data ?? {},
155
- status: version === "published" ? "published" : result?.status ?? "draft",
156
- publishedAt: result?.publishedAt ?? null,
228
+ title: String(state.__eponyme[version][definition.titleField] || slug),
229
+ data: state.__eponyme[version],
230
+ status: version === "published" ? "published" : state.__eponyme.status,
231
+ publishedAt: state.__eponyme.publishedAt,
157
232
  updatedAt: row.updatedAt ? new Date(row.updatedAt).toISOString() : null
158
- };
159
- }));
233
+ }];
234
+ });
160
235
  const visible = version === "draft" ? entries : entries.filter((entry) => entry.publishedAt);
161
236
  const sorted = query.orderBy ? sortCollectionEntries(visible, query.orderBy, query.order ?? "desc") : visible;
162
237
  const skip = Math.max(0, Math.trunc(query.skip ?? 0) || 0);
@@ -181,13 +256,7 @@ export class EponymeService {
181
256
  }
182
257
  if (!path.includes(":slug"))
183
258
  throw new Error(`[Eponyme] previewPaths.${name} must include ":slug" to generate collection sitemap URLs.`);
184
- const rows = await this.client.eponyme.findMany({
185
- where: { name: { startsWith: `${name}/` }, deletedAt: null },
186
- orderBy: [
187
- { updatedAt: "desc" },
188
- { name: "asc" }
189
- ]
190
- });
259
+ const rows = await this.liveCollectionRows(name, true);
191
260
  return rows.flatMap((row) => {
192
261
  const slug = row.name.slice(name.length + 1);
193
262
  if (!slug || slug.includes("/")) return [];
@@ -288,6 +357,7 @@ export class EponymeService {
288
357
  if (!options.dryRun) {
289
358
  const data = state;
290
359
  await this.client.eponyme.upsert({ where: { name: entry.name }, create: { name: entry.name, data }, update: { data } });
360
+ this.invalidate(entry.name);
291
361
  await this.client.eponymeVersion.create({
292
362
  data: {
293
363
  entryName: entry.name,
@@ -330,6 +400,7 @@ export class EponymeService {
330
400
  };
331
401
  try {
332
402
  await this.client.eponyme.create({ data: { name: entryName, data: state } });
403
+ this.invalidate(entryName);
333
404
  } catch (error) {
334
405
  if (isPrismaError(error, "P2002")) return await this.slugTakenError(definition, entryName) ?? { errors: { [definition.slugField]: ["This slug is already in use."] } };
335
406
  throw error;
@@ -357,6 +428,7 @@ export class EponymeService {
357
428
  where: { name, deletedAt: null },
358
429
  data: { deletedAt: /* @__PURE__ */ new Date() }
359
430
  });
431
+ this.invalidate(name);
360
432
  return count > 0;
361
433
  }
362
434
  async restoreCollectionEntry(name) {
@@ -365,6 +437,7 @@ export class EponymeService {
365
437
  where: { name, deletedAt: { not: null } },
366
438
  data: { deletedAt: null }
367
439
  });
440
+ this.invalidate(name);
368
441
  return count > 0;
369
442
  }
370
443
  /** Definitive: `onDelete: Cascade` takes the entry's whole history with it. */
@@ -374,6 +447,7 @@ export class EponymeService {
374
447
  if (!row?.deletedAt) return false;
375
448
  try {
376
449
  await this.client.eponyme.delete({ where: { name } });
450
+ this.invalidate(name);
377
451
  } catch (error) {
378
452
  if (isPrismaError(error, "P2025")) return false;
379
453
  throw error;
@@ -410,7 +484,7 @@ export class EponymeService {
410
484
  async patch(name, payload, action = "publish", actorId) {
411
485
  const schema = this.getSchema(name);
412
486
  if (!schema) return void 0;
413
- const row = await this.loadRow(name);
487
+ const row = await this.loadRow(name, { heal: true });
414
488
  if (!row) return void 0;
415
489
  const { state, updatedAt } = row;
416
490
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) return { errors: { _form: ["Body must be an object."] } };
@@ -467,7 +541,7 @@ export class EponymeService {
467
541
  if (!schema) return void 0;
468
542
  const version = await this.client.eponymeVersion.findUnique({ where: { id: versionId } });
469
543
  if (!version || version.entryName !== name) return void 0;
470
- const current = await this.loadRow(name);
544
+ const current = await this.loadRow(name, { heal: true });
471
545
  if (!current) return void 0;
472
546
  const state = this.normalizeState(schema, version.data);
473
547
  if (!await this.writeState(name, state, current.updatedAt)) return { conflict: true };
@@ -0,0 +1,16 @@
1
+ import type { H3Event } from 'h3';
2
+ /**
3
+ * Published content is identical for every visitor, so both the browser and the CDN may
4
+ * hold on to it. The two windows are deliberately different:
5
+ *
6
+ * - `s-maxage` is the CDN's, and a CDN can be purged — `eponyme:entry:published` is the
7
+ * hook to do it from, so this one can afford to be long.
8
+ * - `max-age` is the browser's, and a browser cache cannot be purged by anyone. Whatever
9
+ * it holds is served until it expires, publication or not, which is why it stays short.
10
+ */
11
+ export declare function setEponymePublicCache(event: H3Event): void;
12
+ /**
13
+ * Drafts and historical versions are unreleased material. `no-store` rather than
14
+ * `no-cache`: nothing may keep a copy at all, not even one it promises to revalidate.
15
+ */
16
+ export declare function setEponymePrivateCache(event: H3Event): void;
@@ -0,0 +1,20 @@
1
+ import { setResponseHeader } from "h3";
2
+ import { useRuntimeConfig } from "nitropack/runtime";
3
+ const STALE_WHILE_REVALIDATE_FACTOR = 12;
4
+ export function setEponymePublicCache(event) {
5
+ const config = useRuntimeConfig().eponymeContent;
6
+ const browser = Math.max(0, Math.trunc(config?.browserCacheSeconds ?? 30));
7
+ const cdn = Math.max(0, Math.trunc(config?.cdnCacheSeconds ?? 300));
8
+ if (!browser && !cdn) {
9
+ setResponseHeader(event, "Cache-Control", "no-cache");
10
+ return;
11
+ }
12
+ setResponseHeader(
13
+ event,
14
+ "Cache-Control",
15
+ `public, max-age=${browser}, s-maxage=${cdn}, stale-while-revalidate=${cdn * STALE_WHILE_REVALIDATE_FACTOR}`
16
+ );
17
+ }
18
+ export function setEponymePrivateCache(event) {
19
+ setResponseHeader(event, "Cache-Control", "no-store");
20
+ }
@@ -0,0 +1,6 @@
1
+ import type { H3Event } from 'h3';
2
+ import type { EponymeCaptchaResult, EponymeCaptchaVerifier } from '../../types/captcha.js';
3
+ export declare const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
4
+ export declare function eponymeCaptchaVerifier(): EponymeCaptchaVerifier;
5
+ export declare function isEponymeCaptchaConfigured(): boolean;
6
+ export declare function verifyEponymeCaptcha(event: H3Event, token: unknown): Promise<EponymeCaptchaResult>;
@@ -0,0 +1,14 @@
1
+ import { getRequestIP } from "h3";
2
+ import captchaVerifier from "#eponyme/captcha";
3
+ export const CAPTCHA_TOKEN_KEY = "_eponyme_captcha";
4
+ export function eponymeCaptchaVerifier() {
5
+ return captchaVerifier;
6
+ }
7
+ export function isEponymeCaptchaConfigured() {
8
+ return eponymeCaptchaVerifier().name !== "none";
9
+ }
10
+ export async function verifyEponymeCaptcha(event, token) {
11
+ if (typeof token !== "string" || !token.trim())
12
+ return { success: false, reason: "Missing captcha token." };
13
+ return eponymeCaptchaVerifier().verify(token, { ip: getRequestIP(event, { xForwardedFor: true }) });
14
+ }
@@ -0,0 +1,16 @@
1
+ export interface EponymeCaptchaResult {
2
+ success: boolean;
3
+ /** Provider-specific reason, surfaced in server logs rather than to the visitor. */
4
+ reason?: string;
5
+ }
6
+ /**
7
+ * Verifies the token a visitor's captcha widget produced. Implemented by an adapter
8
+ * package such as `@karibsen/eponyme-captcha`; the core ships none, so a project
9
+ * without an adapter simply has no captcha.
10
+ */
11
+ export interface EponymeCaptchaVerifier {
12
+ name: string;
13
+ verify: (token: string, context: {
14
+ ip?: string;
15
+ }) => Promise<EponymeCaptchaResult>;
16
+ }
File without changes
@@ -16,3 +16,10 @@ declare module '#eponyme/variables' {
16
16
  const variables: EponymeVariables
17
17
  export default variables
18
18
  }
19
+
20
+ declare module '#eponyme/captcha' {
21
+ import type { EponymeCaptchaVerifier } from './captcha'
22
+
23
+ const verifier: EponymeCaptchaVerifier
24
+ export default verifier
25
+ }
@@ -42,6 +42,8 @@ export interface EponymeFormOptions<T extends EponymeFormSchema = EponymeFormSch
42
42
  honeypot?: string | false;
43
43
  /** In bytes. */
44
44
  maxBodyBytes?: number;
45
+ /** Requires a valid captcha token. Needs an adapter such as `@karibsen/eponyme-captcha`. */
46
+ captcha?: boolean;
45
47
  }
46
48
  export interface EponymeFormDefinitionBase {
47
49
  __eponymeForm: true;
@@ -51,6 +53,7 @@ export interface EponymeFormDefinitionBase {
51
53
  submission: Required<EponymeFormSubmissionOptions>;
52
54
  honeypot: string | false;
53
55
  maxBodyBytes: number;
56
+ captcha: boolean;
54
57
  }
55
58
  export interface EponymeFormDefinition<T extends EponymeFormSchema = EponymeFormSchema> extends EponymeFormDefinitionBase {
56
59
  fields: T;
@@ -1,4 +1,5 @@
1
1
  export * from './auth.js';
2
2
  export * from './eponyme.js';
3
3
  export * from './field.js';
4
+ export * from './captcha.js';
4
5
  export * from './variables.js';
@@ -1,4 +1,5 @@
1
1
  export * from "./auth.js";
2
2
  export * from "./eponyme.js";
3
3
  export * from "./field.js";
4
+ export * from "./captcha.js";
4
5
  export * from "./variables.js";
@@ -0,0 +1,7 @@
1
+ import type { EponymeCaptchaVerifier } from '../types/captcha.js';
2
+ /**
3
+ * Stands in when no adapter is installed. It never claims success, so a form that
4
+ * requires a captcha refuses submissions rather than silently accepting them all.
5
+ */
6
+ declare const verifier: EponymeCaptchaVerifier;
7
+ export default verifier;
@@ -0,0 +1,5 @@
1
+ const verifier = {
2
+ name: "none",
3
+ verify: async () => ({ success: false, reason: "No captcha adapter is installed." })
4
+ };
5
+ export default verifier;
@@ -14,5 +14,12 @@ export declare function summariseEponymeVariables(custom?: EponymeVariables): Ar
14
14
  * than blanked, so a typo shows up on the page instead of silently deleting text.
15
15
  */
16
16
  export declare function interpolateEponymeText(text: string, variables: Record<string, string>): string;
17
- /** Walks strings nested in objects and arrays, leaving every other value alone. */
17
+ /**
18
+ * Walks strings nested in objects and arrays, leaving every other value alone.
19
+ *
20
+ * A branch containing no `{{ name }}` is returned as-is rather than rebuilt, so content
21
+ * without variables — which is most of it — costs no copy. The result therefore shares
22
+ * structure with its input and must be treated as read-only, which is already true of
23
+ * everything the read routes return.
24
+ */
18
25
  export declare function interpolateEponymeValue<T>(value: T, variables: Record<string, string>): T;
@@ -42,11 +42,24 @@ export function interpolateEponymeText(text, variables) {
42
42
  }
43
43
  export function interpolateEponymeValue(value, variables) {
44
44
  if (typeof value === "string") return interpolateEponymeText(value, variables);
45
- if (Array.isArray(value)) return value.map((item) => interpolateEponymeValue(item, variables));
45
+ if (Array.isArray(value)) {
46
+ let changed = false;
47
+ const next = value.map((item) => {
48
+ const interpolated = interpolateEponymeValue(item, variables);
49
+ if (interpolated !== item) changed = true;
50
+ return interpolated;
51
+ });
52
+ return changed ? next : value;
53
+ }
46
54
  if (value && typeof value === "object") {
47
- return Object.fromEntries(
48
- Object.entries(value).map(([key, nested]) => [key, interpolateEponymeValue(nested, variables)])
49
- );
55
+ let changed = false;
56
+ const next = {};
57
+ for (const [key, nested] of Object.entries(value)) {
58
+ const interpolated = interpolateEponymeValue(nested, variables);
59
+ if (interpolated !== nested) changed = true;
60
+ next[key] = interpolated;
61
+ }
62
+ return changed ? next : value;
50
63
  }
51
64
  return value;
52
65
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A lightweight, type-safe content manager for Nuxt.",
5
5
  "license": "MIT",
6
6
  "type": "module",