@karibsen/eponyme 0.4.0 → 0.6.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 (30) hide show
  1. package/README.md +34 -5
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +13 -1
  4. package/dist/runtime/assets/icon.ico +0 -0
  5. package/dist/runtime/assets/logo.png +0 -0
  6. package/dist/runtime/assets.d.ts +9 -0
  7. package/dist/runtime/components/editor/EponymePreviewPanel.vue +4 -3
  8. package/dist/runtime/composables/useEponyme.d.ts +22 -14
  9. package/dist/runtime/composables/useEponyme.js +5 -3
  10. package/dist/runtime/composables/useEponymeCollection.js +6 -4
  11. package/dist/runtime/composables/useEponymeFavicon.d.ts +1 -0
  12. package/dist/runtime/composables/useEponymeFavicon.js +14 -0
  13. package/dist/runtime/pages/EponymeChangePasswordPage.vue +2 -0
  14. package/dist/runtime/pages/EponymeDetailPage.vue +2 -0
  15. package/dist/runtime/pages/EponymeIndexPage.vue +2 -0
  16. package/dist/runtime/pages/EponymeLoginPage.vue +9 -4
  17. package/dist/runtime/pages/EponymeUsersPage.vue +2 -0
  18. package/dist/runtime/server/api/eponyme/[name].get.js +3 -2
  19. package/dist/runtime/server/api/eponyme-collections/[path].get.js +2 -2
  20. package/dist/runtime/server/api/eponyme-sitemap.get.js +2 -2
  21. package/dist/runtime/server/middleware/eponyme-no-store.js +4 -1
  22. package/dist/runtime/server/utils/eponyme-cache.d.ts +3 -1
  23. package/dist/runtime/server/utils/eponyme-cache.js +9 -1
  24. package/dist/runtime/utils/cache-tags.d.ts +46 -0
  25. package/dist/runtime/utils/cache-tags.js +28 -0
  26. package/dist/runtime/utils/hydration-cache.d.ts +10 -0
  27. package/dist/runtime/utils/hydration-cache.js +3 -0
  28. package/dist/runtime/utils/preview.d.ts +10 -0
  29. package/dist/runtime/utils/preview.js +7 -2
  30. package/package.json +1 -1
package/README.md CHANGED
@@ -587,15 +587,44 @@ shorten. A visitor holding a copy keeps it until it expires, publication or not,
587
587
  why the default is deliberately small. Set it to `0` if a publication must be visible
588
588
  immediately to everyone; navigation then costs a round trip again.
589
589
 
590
- The CDN window can be long because it can be purged, and `eponyme:entry:published` is the
591
- place to do it:
590
+ ### Purging the CDN
591
+
592
+ The CDN window can be long because it can be purged. CDNs purge by **tag** rather than by
593
+ URL — one entry appears in several cached responses, its own and its collection's listing —
594
+ so every cacheable response carries `Vercel-Cache-Tag` and `Cache-Tag`:
595
+
596
+ | Response | Tags |
597
+ |---|---|
598
+ | `pages/homepage` | `eponyme`, `eponyme:pages/homepage` |
599
+ | `articles/my-article` | `eponyme`, `eponyme:articles/my-article`, `eponyme:articles` |
600
+ | The `articles` listing | `eponyme`, `eponyme:articles` |
601
+
602
+ `getEponymeCacheTags()` returns exactly those tags and is auto-imported into server code,
603
+ so a listener purges precisely what the responses were tagged with. On Vercel:
592
604
 
593
605
  ```ts
594
- nitroApp.hooks.hook('eponyme:entry:published', async ({ name, collection }) => {
595
- await purgeCdnCache(collection ? `/${collection.name}/${collection.slug}` : `/${name}`)
606
+ // server/plugins/purge.ts
607
+ import { invalidateByTag } from '@vercel/functions'
608
+
609
+ export default defineNitroPlugin((nitroApp) => {
610
+ const purge = async ({ name, collection }: { name: string, collection?: { name: string } }) => {
611
+ if (!import.meta.env.VERCEL) return
612
+ await invalidateByTag(getEponymeCacheTags(name, collection)).catch(() => {})
613
+ }
614
+
615
+ nitroApp.hooks.hook('eponyme:entry:published', purge)
616
+ nitroApp.hooks.hook('eponyme:entry:restored', purge)
617
+ nitroApp.hooks.hook('eponyme:entry:trashed', purge)
618
+ nitroApp.hooks.hook('eponyme:entry:untrashed', purge)
619
+ nitroApp.hooks.hook('eponyme:entry:purged', purge)
596
620
  })
597
621
  ```
598
622
 
623
+ Invalidating marks the entry stale and revalidates in the background, so no visitor waits
624
+ for it. A failed purge is swallowed: a CDN that cannot be reached must not turn a
625
+ successful publication into an error the editor cannot act on. The worst case is the
626
+ content appearing when `cdnCacheSeconds` expires on its own.
627
+
599
628
  **Only published content is ever cached.** Drafts, historical versions, `raw=1` reads,
600
629
  submissions, the trash, the user list and the session route all answer `no-store`. That is
601
630
  enforced by a server middleware that marks every Eponyme route uncacheable, which the few
@@ -806,7 +835,7 @@ a trash move leaves the entry itself untouched.
806
835
 
807
836
  ## Current status
808
837
 
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:
838
+ Eponyme is at version `0.5.0`. It is ready for controlled projects and production pilots. A few workflows still need hardening before a broad public release:
810
839
 
811
840
  - 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
812
841
  - Retention controls for the trash: entries stay there until someone empties it
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
3
  "configKey": "eponyme",
4
- "version": "0.4.0",
4
+ "version": "0.6.0",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "3.6.1"
package/dist/module.mjs CHANGED
@@ -2,6 +2,7 @@ import { defineNuxtModule, useLogger, createResolver, findPath, addImports, addI
2
2
  import pc from 'picocolors';
3
3
  import tailwindcss from '@tailwindcss/vite';
4
4
  import { resolve } from 'node:path';
5
+ import { tagPreviewPathRoutes } from '../dist/runtime/utils/cache-tags.js';
5
6
 
6
7
  const module$1 = defineNuxtModule({
7
8
  meta: {
@@ -78,6 +79,14 @@ const module$1 = defineNuxtModule({
78
79
  browserCacheSeconds: options.browserCacheSeconds ?? 30,
79
80
  cdnCacheSeconds: options.cdnCacheSeconds ?? 300
80
81
  };
82
+ const tagged = tagPreviewPathRoutes(options.previewPaths ?? {}, nuxt.options.routeRules ??= {});
83
+ if (tagged.length) {
84
+ const width = Math.max(...tagged.map(({ route }) => route.length));
85
+ logger.info(
86
+ `Cache tags added to ${tagged.length} public route${tagged.length > 1 ? "s" : ""}, so publishing can purge them:
87
+ ` + tagged.map(({ route, tag }) => ` ${route.padEnd(width)} ${tag}`).join("\n")
88
+ );
89
+ }
81
90
  const preparing = Boolean(nuxt.options._prepare);
82
91
  if (!options.prismaClient) {
83
92
  if (!preparing) throw new Error(`${pc.red("[Eponyme]")} eponyme.prismaClient is required.`);
@@ -106,7 +115,10 @@ Note that '~/' points at ${nuxt.options.srcDir}, so a server module needs '~~/'.
106
115
  { name: "getEponymeSitemapEntries", from: resolver.resolve("./runtime/server/utils/eponyme-sitemap") },
107
116
  // A `custom` form posts to the host application's own route, so this is what
108
117
  // keeps server-side validation as the security boundary.
109
- { name: "validateEponymeForm", from: resolver.resolve("./runtime/server/utils/eponyme-form") }
118
+ { name: "validateEponymeForm", from: resolver.resolve("./runtime/server/utils/eponyme-form") },
119
+ // The tags a CDN purge has to invalidate when an entry changes, matching exactly what
120
+ // the cached responses were tagged with.
121
+ { name: "getEponymeCacheTags", from: resolver.resolve("./runtime/server/utils/eponyme-cache") }
110
122
  ]);
111
123
  addRouteMiddleware({
112
124
  name: "eponyme-auth",
Binary file
Binary file
@@ -0,0 +1,9 @@
1
+ declare module '*.ico?url' {
2
+ const url: string
3
+ export default url
4
+ }
5
+
6
+ declare module '*.png?url' {
7
+ const url: string
8
+ export default url
9
+ }
@@ -3,6 +3,7 @@ import { computed, ref, watch } from "vue";
3
3
  import EPButton from "../ui/EPButton.vue";
4
4
  import EPSelect from "../ui/EPSelect.vue";
5
5
  import { formatVersionDate, useEponymeHistory } from "../../composables/useEponymeHistory";
6
+ import { EPONYME_PREVIEW_QUERY, EPONYME_PREVIEW_TOKEN_QUERY, EPONYME_PREVIEW_VERSION_QUERY } from "../../utils/preview";
6
7
  const props = defineProps({
7
8
  name: { type: String, required: true },
8
9
  path: { type: String, required: true },
@@ -25,9 +26,9 @@ const options = computed(() => [
25
26
  ]);
26
27
  const previewUrl = computed(() => {
27
28
  const url = new URL(props.path, "http://placeholder");
28
- url.searchParams.set("__eponyme_preview", props.name);
29
- url.searchParams.set("__eponyme_preview_version", selected.value);
30
- url.searchParams.set("__eponyme_preview_token", previewToken.value);
29
+ url.searchParams.set(EPONYME_PREVIEW_QUERY, props.name);
30
+ url.searchParams.set(EPONYME_PREVIEW_VERSION_QUERY, selected.value);
31
+ url.searchParams.set(EPONYME_PREVIEW_TOKEN_QUERY, previewToken.value);
31
32
  return `${url.pathname}${url.search}`;
32
33
  });
33
34
  async function reload() {
@@ -1,4 +1,4 @@
1
- import type { Ref } from 'vue';
1
+ import type { ComputedRef, Ref } from 'vue';
2
2
  import type eponymeConfig from '#eponyme/config';
3
3
  import type { EponymeDataByName, EponymeName } from '../types/index.js';
4
4
  import type { EponymeAction, EponymeStatus, EponymeVersionSelector } from '../server/services/eponyme-store.js';
@@ -9,18 +9,26 @@ export interface UseEponymeOptions {
9
9
  /** Keep `{{ variables }}` unresolved. The dashboard editor needs the source text. */
10
10
  raw?: boolean;
11
11
  }
12
- /** Read and save a configured eponyme through the public or draft API. */
13
- export declare function useEponyme<const Name extends ConfigEponymeName>(name: Name, options?: UseEponymeOptions): {
14
- data: Ref<EponymeDataByName<any, Name> | undefined>;
15
- status: import("vue").ComputedRef<EponymeStatus>;
16
- publishedAt: import("vue").ComputedRef<string | null>;
17
- pending: import("vue").ComputedRef<boolean>;
18
- error: Ref<import("nuxt/app").NuxtError<unknown> | undefined, import("nuxt/app").NuxtError<unknown> | undefined>;
19
- errors: Ref<ValidationErrors, ValidationErrors>;
12
+ /**
13
+ * Named, and annotated on the function below, rather than left to inference.
14
+ *
15
+ * An inferred return type has to be expanded when the declaration files are generated, and
16
+ * the expansion loses `typeof eponymeConfig`: it came out as `EponymeDataByName<any, Name>`,
17
+ * which collapses every field to the union of all possible field values. Source and
18
+ * playground were unaffected — they read `src` — so only installed versions of the package
19
+ * lost their types, on the one composable everything else goes through.
20
+ */
21
+ export interface UseEponymeResult<Data extends Record<string, unknown>> {
22
+ data: Ref<Data | undefined>;
23
+ status: ComputedRef<EponymeStatus>;
24
+ publishedAt: ComputedRef<string | null>;
25
+ pending: ComputedRef<boolean>;
26
+ error: Ref<Error | null | undefined>;
27
+ errors: Ref<ValidationErrors>;
20
28
  refresh: () => Promise<void>;
21
- save: {
22
- (): Promise<EponymeDataByName<any, Name> | undefined>;
23
- (patch: Partial<EponymeDataByName<any, Name>>, action?: EponymeAction): Promise<EponymeDataByName<any, Name> | undefined>;
24
- };
25
- };
29
+ /** Without a patch, saves the data currently held. Publishes unless told otherwise. */
30
+ save: (patch?: Partial<Data>, action?: EponymeAction) => Promise<Data | undefined>;
31
+ }
32
+ /** Read and save a configured eponyme through the public or draft API. */
33
+ export declare function useEponyme<const Name extends ConfigEponymeName>(name: Name, options?: UseEponymeOptions): UseEponymeResult<EponymeDataByName<typeof eponymeConfig, Name>>;
26
34
  export {};
@@ -1,7 +1,7 @@
1
1
  import { clearNuxtData, useAsyncData, useRequestFetch, useRoute } from "#app";
2
2
  import { computed, ref } from "vue";
3
3
  import { readPreviewQuery, readPreviewVersion } from "../utils/preview.js";
4
- import { cacheDuringHydrationOnly } from "../utils/hydration-cache.js";
4
+ import { cacheForPublicRead } from "../utils/hydration-cache.js";
5
5
  export function useEponyme(name, options = {}) {
6
6
  const route = useRoute();
7
7
  const preview = readPreviewQuery(route.query);
@@ -10,11 +10,13 @@ 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
+ const isPublicContent = version === "published" && !options.raw;
14
+ const fetchCache = isPublicContent ? void 0 : "no-store";
15
+ const isPreviewRead = options.version === void 0 && !isPublicContent;
14
16
  const { data: response, pending: loading, error, refresh: load } = useAsyncData(
15
17
  cacheKey,
16
18
  () => requestFetch(`/api/eponyme/${name}`, { query: { version, raw: options.raw ? 1 : void 0 }, cache: fetchCache }),
17
- { getCachedData: cacheDuringHydrationOnly }
19
+ { server: !isPreviewRead, getCachedData: cacheForPublicRead(isPublicContent) }
18
20
  );
19
21
  const data = computed(() => response.value?.data);
20
22
  const status = computed(() => response.value?.status ?? "published");
@@ -2,7 +2,7 @@ import { useAsyncData, useRequestFetch, useRoute } from "#app";
2
2
  import { useEventListener } from "@vueuse/core";
3
3
  import { computed } from "vue";
4
4
  import { readPreviewQuery, readPreviewVersion } from "../utils/preview.js";
5
- import { cacheDuringHydrationOnly } from "../utils/hydration-cache.js";
5
+ import { cacheForPublicRead } from "../utils/hydration-cache.js";
6
6
  export function useEponymeCollection(name, options = {}) {
7
7
  const requestFetch = useRequestFetch();
8
8
  const query = {
@@ -17,7 +17,7 @@ export function useEponymeCollection(name, options = {}) {
17
17
  `eponyme:collection:public:${name}:${options.take ?? ""}:${options.skip ?? ""}:${options.orderBy ?? ""}:${options.order ?? ""}`,
18
18
  // Always the published listing, which is publicly cacheable: the browser cache may answer it.
19
19
  () => requestFetch(`/api/eponyme-collections/${name}`, { query }),
20
- { getCachedData: cacheDuringHydrationOnly }
20
+ { getCachedData: cacheForPublicRead(true) }
21
21
  );
22
22
  const entries = computed(() => result.data.value?.entries ?? []);
23
23
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
@@ -36,14 +36,16 @@ export function useEponymeCollectionEntry(name, slug, options = {}) {
36
36
  const preview = readPreviewQuery(route.query);
37
37
  const version = options.version ?? (preview.entry === `${name}/${slug}` ? readPreviewVersion(preview.version) : "published");
38
38
  const requestFetch = useRequestFetch();
39
+ const isPublicContent = version === "published";
40
+ const isPreviewRead = options.version === void 0 && !isPublicContent;
39
41
  const result = useAsyncData(
40
42
  `eponyme:collection-entry:${name}:${slug}:${version}`,
41
43
  () => requestFetch(`/api/eponyme/${name}/${encodeURIComponent(slug)}`, {
42
44
  query: { version },
43
45
  // A draft or a historical version must never be stored, whatever the server says.
44
- cache: version === "published" ? void 0 : "no-store"
46
+ cache: isPublicContent ? void 0 : "no-store"
45
47
  }),
46
- { getCachedData: cacheDuringHydrationOnly }
48
+ { server: !isPreviewRead, getCachedData: cacheForPublicRead(isPublicContent) }
47
49
  );
48
50
  if (import.meta.client) useEventListener(window, "focus", () => void result.refresh());
49
51
  return {
@@ -0,0 +1 @@
1
+ export declare function useEponymeFavicon(): void;
@@ -0,0 +1,14 @@
1
+ import { useHead } from "#app";
2
+ import iconUrl from "../assets/icon.ico?url";
3
+ export function useEponymeFavicon() {
4
+ useHead({
5
+ link: [
6
+ {
7
+ key: "eponyme-favicon",
8
+ rel: "icon",
9
+ type: "image/x-icon",
10
+ href: iconUrl
11
+ }
12
+ ]
13
+ });
14
+ }
@@ -2,9 +2,11 @@
2
2
  import { navigateTo, useRuntimeConfig, useSeoMeta } from "#app";
3
3
  import { computed, ref } from "vue";
4
4
  import { useEponymeAuth } from "../composables/useEponymeAuth";
5
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
5
6
  import EPButton from "../components/ui/EPButton.vue";
6
7
  import EPFormField from "../components/ui/EPFormField.vue";
7
8
  import EPInputText from "../components/ui/EPInputText.vue";
9
+ useEponymeFavicon();
8
10
  useSeoMeta({ title: "Change password \xB7 Eponyme" });
9
11
  const auth = useEponymeAuth();
10
12
  const currentPassword = ref("");
@@ -9,6 +9,7 @@ import EponymeFolderPage from "../components/editor/EponymeFolderPage.vue";
9
9
  import EponymePageNavigation from "../components/editor/EponymePageNavigation.vue";
10
10
  import EponymeSidebar from "../components/editor/EponymeSidebar.vue";
11
11
  import { useEponymeConfig } from "../composables/useEponymeConfig";
12
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
12
13
  import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../utils/get-eponyme-schemas";
13
14
  const route = useRoute();
14
15
  const config = useEponymeConfig();
@@ -29,6 +30,7 @@ const isFolder = computed(() => !schemas[eponymeName.value] && !collection.value
29
30
  function label(name) {
30
31
  return name.replace(/[-_]/g, " ").replace(/\b\w/g, (char) => char.toUpperCase());
31
32
  }
33
+ useEponymeFavicon();
32
34
  useSeoMeta({
33
35
  title: () => `${label(eponymeName.value.split("/").at(-1) ?? eponymeName.value)} \xB7 Eponyme`
34
36
  });
@@ -8,6 +8,7 @@ import EPDialog from "../components/ui/EPDialog.vue";
8
8
  import EPToast from "../components/ui/EPToast.vue";
9
9
  import { useEponymeAuth } from "../composables/useEponymeAuth";
10
10
  import { useEponymeConfig } from "../composables/useEponymeConfig";
11
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
11
12
  import { getEponymeCollections, getEponymeForms, getEponymeSchemas } from "../utils/get-eponyme-schemas";
12
13
  const config = useEponymeConfig();
13
14
  const route = useRoute();
@@ -17,6 +18,7 @@ const eponymes = getEponymeSchemas(config);
17
18
  const collections = getEponymeCollections(config);
18
19
  const forms = getEponymeForms(config);
19
20
  const entries = { ...eponymes, ...collections, ...forms };
21
+ useEponymeFavicon();
20
22
  useSeoMeta({ title: "Eponyme" });
21
23
  const fileInput = ref();
22
24
  const exporting = ref(false);
@@ -2,15 +2,18 @@
2
2
  import { navigateTo, useRoute, useRuntimeConfig, useSeoMeta } from "#app";
3
3
  import { computed, ref } from "vue";
4
4
  import { useEponymeAuth } from "../composables/useEponymeAuth";
5
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
5
6
  import EPButton from "../components/ui/EPButton.vue";
6
7
  import EPFormField from "../components/ui/EPFormField.vue";
7
8
  import EPInputText from "../components/ui/EPInputText.vue";
9
+ import logoUrl from "../assets/logo.png?url";
8
10
  const route = useRoute();
9
11
  const auth = useEponymeAuth();
10
12
  const username = ref("");
11
13
  const password = ref("");
12
14
  const error = ref("");
13
15
  const dashboardPath = computed(() => useRuntimeConfig().public.eponyme?.dashboardPath ?? "/__eponyme");
16
+ useEponymeFavicon();
14
17
  useSeoMeta({ title: "Sign in \xB7 Eponyme" });
15
18
  const existingUser = await auth.refresh();
16
19
  if (existingUser)
@@ -37,10 +40,12 @@ async function navigateAfterLogin(mustChangePassword) {
37
40
  <template>
38
41
  <main class="eponyme-root ep:flex ep:min-h-screen ep:items-center ep:justify-center ep:bg-theme-ep ep:px-6 ep:py-12 ep:font-sans ep:text-text-ep">
39
42
  <section class="ep:w-full ep:max-w-sm">
40
- <p class="ep:m-0 ep:text-[11px] ep:font-semibold ep:tracking-widest ep:text-muted-ep ep:uppercase">
41
- Eponyme
42
- </p>
43
- <h1 class="ep:mt-2 ep:mb-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
43
+ <img
44
+ :src="logoUrl"
45
+ alt="Eponyme"
46
+ class="ep:block ep:size-14"
47
+ >
48
+ <h1 class="ep:mt-4 ep:mb-2 ep:text-3xl ep:font-semibold ep:tracking-tight ep:text-white">
44
49
  Sign in
45
50
  </h1>
46
51
  <p class="ep:mt-0 ep:mb-8 ep:text-sm ep:leading-relaxed ep:text-muted-ep">
@@ -3,6 +3,7 @@ import { useRequestFetch, useRoute, useSeoMeta } from "#app";
3
3
  import { computed, ref } from "vue";
4
4
  import EponymeCopy from "../components/editor/EponymeCopy.vue";
5
5
  import EponymeSidebar from "../components/editor/EponymeSidebar.vue";
6
+ import { useEponymeFavicon } from "../composables/useEponymeFavicon";
6
7
  import EPButton from "../components/ui/EPButton.vue";
7
8
  import EPDialog from "../components/ui/EPDialog.vue";
8
9
  import EPFormField from "../components/ui/EPFormField.vue";
@@ -11,6 +12,7 @@ import EPSelect from "../components/ui/EPSelect.vue";
11
12
  import EPSwitch from "../components/ui/EPSwitch.vue";
12
13
  import EPTooltip from "../components/ui/EPTooltip.vue";
13
14
  import EPAvatar from "../components/ui/EPAvatar.vue";
15
+ useEponymeFavicon();
14
16
  useSeoMeta({ title: "Users \xB7 Eponyme" });
15
17
  const route = useRoute();
16
18
  const requestFetch = useRequestFetch();
@@ -1,7 +1,8 @@
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
+ import { getEponymeCacheTags, setEponymePublicCache } from "../../utils/eponyme-cache.js";
5
+ import { splitEponymeCollectionEntry } from "../../utils/eponyme-entry.js";
5
6
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
6
7
  function readVersion(raw) {
7
8
  if (raw === "draft") return "draft";
@@ -14,7 +15,7 @@ export default defineEventHandler(async (event) => {
14
15
  const version = readVersion(requestedVersion);
15
16
  const raw = Boolean(getQuery(event).raw);
16
17
  if (version !== "published") await requireEponymeUser(event);
17
- else if (!raw) setEponymePublicCache(event);
18
+ else if (!raw) setEponymePublicCache(event, getEponymeCacheTags(name, splitEponymeCollectionEntry(useEponymeService(), name)?.name));
18
19
  const result = name ? await useEponymeService().getResult(name, version) : void 0;
19
20
  if (!result) throw createError({ statusCode: 404, statusMessage: "Eponyme entry not found." });
20
21
  const data = raw ? result.data : interpolateEponymeContent(result.data);
@@ -1,7 +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
+ import { getEponymeCacheTags, setEponymePublicCache } from "../../utils/eponyme-cache.js";
5
5
  import { interpolateEponymeContent } from "../../utils/eponyme-variables.js";
6
6
  const MAX_TAKE = 200;
7
7
  function readCount(raw, min, max) {
@@ -15,7 +15,7 @@ export default defineEventHandler(async (event) => {
15
15
  const query = getQuery(event);
16
16
  const version = query.version === "draft" ? "draft" : "published";
17
17
  if (version === "draft") await requireEponymeUser(event);
18
- else if (!query.raw) setEponymePublicCache(event);
18
+ else if (!query.raw) setEponymePublicCache(event, getEponymeCacheTags(name));
19
19
  if (!name) throw createError({ statusCode: 404, statusMessage: "Eponyme collection not found." });
20
20
  const service = useEponymeService();
21
21
  const orderBy = query.orderBy === void 0 || query.orderBy === "" ? void 0 : String(query.orderBy);
@@ -1,7 +1,7 @@
1
1
  import { defineEventHandler } from "h3";
2
- import { setEponymePublicCache } from "../utils/eponyme-cache.js";
2
+ import { getEponymeCacheTags, setEponymePublicCache } from "../utils/eponyme-cache.js";
3
3
  import { getEponymeSitemapEntries } from "../utils/eponyme-sitemap.js";
4
4
  export default defineEventHandler(async (event) => {
5
- setEponymePublicCache(event);
5
+ setEponymePublicCache(event, getEponymeCacheTags("sitemap"));
6
6
  return { entries: await getEponymeSitemapEntries() };
7
7
  });
@@ -1,6 +1,9 @@
1
1
  import { defineEventHandler, getRequestURL } from "h3";
2
2
  import { setEponymePrivateCache } from "../utils/eponyme-cache.js";
3
+ import { EPONYME_PREVIEW_QUERY } from "../../utils/preview.js";
3
4
  export default defineEventHandler((event) => {
4
- if (!getRequestURL(event).pathname.startsWith("/api/eponyme")) return;
5
+ const url = getRequestURL(event);
6
+ if (url.searchParams.has(EPONYME_PREVIEW_QUERY)) return setEponymePrivateCache(event);
7
+ if (!url.pathname.startsWith("/api/eponyme")) return;
5
8
  setEponymePrivateCache(event);
6
9
  });
@@ -1,4 +1,6 @@
1
1
  import type { H3Event } from 'h3';
2
+ /** Re-exported so server code keeps one import for everything cache-related. */
3
+ export { getEponymeCacheTags } from '../../utils/cache-tags.js';
2
4
  /**
3
5
  * Published content is identical for every visitor, so both the browser and the CDN may
4
6
  * hold on to it. The two windows are deliberately different:
@@ -8,7 +10,7 @@ import type { H3Event } from 'h3';
8
10
  * - `max-age` is the browser's, and a browser cache cannot be purged by anyone. Whatever
9
11
  * it holds is served until it expires, publication or not, which is why it stays short.
10
12
  */
11
- export declare function setEponymePublicCache(event: H3Event): void;
13
+ export declare function setEponymePublicCache(event: H3Event, tags?: string[]): void;
12
14
  /**
13
15
  * Drafts and historical versions are unreleased material. `no-store` rather than
14
16
  * `no-cache`: nothing may keep a copy at all, not even one it promises to revalidate.
@@ -1,7 +1,14 @@
1
1
  import { setResponseHeader } from "h3";
2
2
  import { useRuntimeConfig } from "nitropack/runtime";
3
3
  const STALE_WHILE_REVALIDATE_FACTOR = 12;
4
- export function setEponymePublicCache(event) {
4
+ export { getEponymeCacheTags } from "../../utils/cache-tags.js";
5
+ function setCacheTags(event, tags) {
6
+ if (!tags.length) return;
7
+ const value = tags.join(",");
8
+ setResponseHeader(event, "Vercel-Cache-Tag", value);
9
+ setResponseHeader(event, "Cache-Tag", value);
10
+ }
11
+ export function setEponymePublicCache(event, tags = []) {
5
12
  const config = useRuntimeConfig().eponymeContent;
6
13
  const browser = Math.max(0, Math.trunc(config?.browserCacheSeconds ?? 30));
7
14
  const cdn = Math.max(0, Math.trunc(config?.cdnCacheSeconds ?? 300));
@@ -14,6 +21,7 @@ export function setEponymePublicCache(event) {
14
21
  "Cache-Control",
15
22
  `public, max-age=${browser}, s-maxage=${cdn}, stale-while-revalidate=${cdn * STALE_WHILE_REVALIDATE_FACTOR}`
16
23
  );
24
+ setCacheTags(event, tags);
17
25
  }
18
26
  export function setEponymePrivateCache(event) {
19
27
  setResponseHeader(event, "Cache-Control", "no-store");
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Single source of truth for Eponyme cache tags, shared by the API responses that carry
3
+ * them, the module that tags the host's page routes at build time, and any purge the host
4
+ * runs on publication. Kept free of server imports so `module.ts` can use it too: a page
5
+ * and its API response must be tagged by the same code, or a purge would drop one and
6
+ * leave the other.
7
+ *
8
+ * A CDN cannot usually be purged by URL — Vercel and Cloudflare both purge by tag — and one
9
+ * entry appears in more than one cached response: its own, its collection's listing, and the
10
+ * page that renders it. Tagging all of them is what lets publishing an article drop the
11
+ * article, the index that lists it, and the HTML a visitor would otherwise keep receiving.
12
+ *
13
+ * A comma is the delimiter every CDN uses for this header, so it is stripped rather than
14
+ * escaped: a tag containing one would silently become two.
15
+ */
16
+ export declare function getEponymeCacheTags(name: string, collection?: {
17
+ name: string;
18
+ } | string): string[];
19
+ /** The shape of `nuxt.options.routeRules` this needs, declared so this file stays build-safe. */
20
+ type TaggableRouteRules = Record<string, {
21
+ headers?: Record<string, string>;
22
+ }>;
23
+ /**
24
+ * Tags the host's public routes with the same tags their API responses carry.
25
+ *
26
+ * A published entry ends up in two caches: the JSON the browser reads, and — as soon as the
27
+ * host puts a route under `swr`, `isr` or `prerender` — the HTML itself. Only the first was
28
+ * tagged, so a purge on publication dropped the JSON and left visitors on the old page.
29
+ * `previewPaths` already declares which route renders which entry, which is exactly the
30
+ * mapping needed to tag the other one, once, at build time.
31
+ *
32
+ * A collection pattern cannot name a slug in a route rule, so its pages carry the
33
+ * collection-level tag: publishing one article purges every article page and the index that
34
+ * lists them. Broader than strictly needed, and never wrong — tagging per entry would mean
35
+ * tracking every entry read during a render, and nitro's request context is not shared
36
+ * across the internal fetch that reads them.
37
+ *
38
+ * A header the host wrote wins: this only fills in what nobody set. Returns what it did, so
39
+ * the module can say it out loud: writing into someone else's `routeRules` is invisible to
40
+ * whoever reads their own config, and an effect nobody can see is one nobody can debug.
41
+ */
42
+ export declare function tagPreviewPathRoutes(previewPaths: Record<string, string>, routeRules: TaggableRouteRules): {
43
+ route: string;
44
+ tag: string;
45
+ }[];
46
+ export {};
@@ -0,0 +1,28 @@
1
+ export function getEponymeCacheTags(name, collection) {
2
+ const collectionName = typeof collection === "string" ? collection : collection?.name;
3
+ const tags = ["eponyme", `eponyme:${name}`];
4
+ if (collectionName) tags.push(`eponyme:${collectionName}`);
5
+ return [...new Set(tags)].map((tag) => tag.replace(/,/g, "").slice(0, 256));
6
+ }
7
+ export function tagPreviewPathRoutes(previewPaths, routeRules) {
8
+ const tagged = [];
9
+ for (const [name, path] of Object.entries(previewPaths)) {
10
+ if (!path.startsWith("/")) continue;
11
+ const isCollection = path.includes(":slug");
12
+ const route = isCollection ? path.replace(/:slug\b.*$/, "**") : path;
13
+ const tags = getEponymeCacheTags(name, isCollection ? name : void 0);
14
+ const value = tags.join(",");
15
+ const existing = routeRules[route] ?? {};
16
+ routeRules[route] = {
17
+ ...existing,
18
+ headers: {
19
+ // Vercel reads its own header; `Cache-Tag` is what Cloudflare and Fastly-style CDNs read.
20
+ "Vercel-Cache-Tag": value,
21
+ "Cache-Tag": value,
22
+ ...existing.headers
23
+ }
24
+ };
25
+ tagged.push({ route, tag: tags[tags.length - 1] });
26
+ }
27
+ return tagged;
28
+ }
@@ -9,3 +9,13 @@ import type { NuxtApp } from '#app';
9
9
  * first paint faithful and still refetches on every later navigation.
10
10
  */
11
11
  export declare function cacheDuringHydrationOnly<T>(key: string, nuxtApp: NuxtApp): T | undefined;
12
+ /**
13
+ * `getCachedData` for one read, or `undefined` to keep Nuxt's own policy.
14
+ *
15
+ * Published content is the same for every visitor and already answers with a public
16
+ * `Cache-Control`, so Nuxt's default is exactly right: the SSR payload while hydrating,
17
+ * then `nuxtApp.static.data`, which prerendered and prefetched payloads fill. A client
18
+ * navigation to a prerendered route then costs no request at all. Unreleased material
19
+ * keeps refetching after hydration.
20
+ */
21
+ export declare function cacheForPublicRead(isPublic: boolean): typeof cacheDuringHydrationOnly | undefined;
@@ -1,3 +1,6 @@
1
1
  export function cacheDuringHydrationOnly(key, nuxtApp) {
2
2
  return nuxtApp.isHydrating ? nuxtApp.payload.data[key] : void 0;
3
3
  }
4
+ export function cacheForPublicRead(isPublic) {
5
+ return isPublic ? void 0 : cacheDuringHydrationOnly;
6
+ }
@@ -20,6 +20,16 @@ export declare function splitCollectionEntry(collectionNames: string[], name: st
20
20
  * returns `previewPaths[collection]` with `:slug` filled in.
21
21
  */
22
22
  export declare function resolvePreviewPath(previewPaths: Record<string, string>, collectionNames: string[], name: string): string | undefined;
23
+ /**
24
+ * The query parameters that turn a public route into a preview.
25
+ *
26
+ * Named here because three places have to agree on them: the panel that builds the URL,
27
+ * the composables that read it, and the middleware that keeps such a response out of
28
+ * every cache. A typo in any one of them fails silently, in the direction of leaking.
29
+ */
30
+ export declare const EPONYME_PREVIEW_QUERY = "__eponyme_preview";
31
+ export declare const EPONYME_PREVIEW_VERSION_QUERY = "__eponyme_preview_version";
32
+ export declare const EPONYME_PREVIEW_TOKEN_QUERY = "__eponyme_preview_token";
23
33
  /** Reads `?__eponyme_preview_version=` — `draft` by default, or a numeric history id. */
24
34
  export declare function readPreviewVersion(raw: unknown): PreviewVersion;
25
35
  /** Reads the preview query params, unwrapping the repeated-param array form. */
@@ -18,13 +18,18 @@ export function resolvePreviewPath(previewPaths, collectionNames, name) {
18
18
  if (!pattern || !pattern.includes(":slug")) return void 0;
19
19
  return applyPreviewSlug(pattern, entry.slug);
20
20
  }
21
+ export const EPONYME_PREVIEW_QUERY = "__eponyme_preview";
22
+ export const EPONYME_PREVIEW_VERSION_QUERY = "__eponyme_preview_version";
23
+ export const EPONYME_PREVIEW_TOKEN_QUERY = "__eponyme_preview_token";
21
24
  export function readPreviewVersion(raw) {
22
25
  if (raw === "published") return "published";
23
26
  const id = Number(raw);
24
27
  return Number.isSafeInteger(id) && id > 0 ? id : "draft";
25
28
  }
26
29
  export function readPreviewQuery(query) {
27
- const entry = Array.isArray(query.__eponyme_preview) ? query.__eponyme_preview[0] : query.__eponyme_preview;
28
- const version = Array.isArray(query.__eponyme_preview_version) ? query.__eponyme_preview_version[0] : query.__eponyme_preview_version;
30
+ const rawEntry = query[EPONYME_PREVIEW_QUERY];
31
+ const rawVersion = query[EPONYME_PREVIEW_VERSION_QUERY];
32
+ const entry = Array.isArray(rawEntry) ? rawEntry[0] : rawEntry;
33
+ const version = Array.isArray(rawVersion) ? rawVersion[0] : rawVersion;
29
34
  return { entry: entry === void 0 || entry === null ? void 0 : String(entry), version };
30
35
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@karibsen/eponyme",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "A lightweight, type-safe content manager for Nuxt.",
5
5
  "license": "MIT",
6
6
  "type": "module",