@hollware/nuxt 0.10.0 → 0.11.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.
package/README.md CHANGED
@@ -49,6 +49,27 @@ const { data } = await useHollwareEntry('blog', 'en', useRoute().params.slug as
49
49
  Every page carries `<meta name="generator" content="Hollware">`; set `hollware.generator: false`
50
50
  to leave it out.
51
51
 
52
+ ## Previews
53
+
54
+ "Preview on the site" in the admin opens the entry's page with `?hollware-preview=<token>`, a
55
+ signed link for one entry that works for one hour. The module answers that request with a `303` to
56
+ the same URL without the parameter (other parameters kept) and keeps the token in the
57
+ `hollware_preview` cookie: `HttpOnly`, `SameSite=Lax`, `Path=/`, an hour, and `Secure` on every
58
+ request but `http://localhost` and `http://127.0.0.1`, so local development works. The token is in
59
+ a URL once: not in the address bar, the history, a `Referer` or a copied link, but for the first
60
+ request's line in the logs. A value not shaped like a token sets no cookie.
61
+
62
+ A request with the cookie reads the latest revision of that entry through `useHollwareEntry`, a
63
+ draft included, and nothing keeps what it answers: no cache tag, `Cache-Control: no-store`, and the
64
+ cache of `hollware.cache` neither serves nor stores it. A token that is expired or not valid is
65
+ refused by Hollware: the page is what it is without a token (a 404 for a draft) and the response
66
+ clears the cookie. A token for another entry is answered as no entry and keeps the cookie. It needs
67
+ the Worker secret `HOLLWARE_SECRET` in Hollware.
68
+
69
+ The redirect adds `hollware-view=<random>` to the URL: a value of no secret, new for each link, so
70
+ the previewed page is at a URL no cache holds, and Workers Cache in front of the site (`cache:
71
+ 'purge'`) cannot answer it with the published page before the Worker reads the cookie.
72
+
52
73
  ## Links
53
74
 
54
75
  - Docs: <https://hollware.com>
package/dist/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { PREVIEW_HEADER } from "./runtime/tags.mjs";
1
2
  import { addComponent, addImports, addPlugin, addServerHandler, addServerImports, addServerPlugin, createResolver, defineNuxtModule } from "@nuxt/kit";
2
3
  //#region src/index.ts
3
4
  const name = "@hollware/nuxt";
@@ -53,6 +54,11 @@ var src_default = defineNuxtModule({
53
54
  filePath: resolve("./runtime/components/HollwareContent")
54
55
  });
55
56
  if (options.generator) addPlugin(resolve("./runtime/generator"));
57
+ addServerPlugin(resolve("./runtime/server/preview"));
58
+ addServerHandler({
59
+ middleware: true,
60
+ handler: resolve("./runtime/server/preview-exchange")
61
+ });
56
62
  if (options.cache === "purge") {
57
63
  addServerPlugin(resolve("./runtime/server/cache"));
58
64
  addServerHandler({
@@ -62,7 +68,11 @@ var src_default = defineNuxtModule({
62
68
  });
63
69
  }
64
70
  if (typeof options.cache === "number" && options.cache > 0) {
65
- const pages = { swr: options.cache };
71
+ const pages = { cache: {
72
+ swr: true,
73
+ maxAge: options.cache,
74
+ varies: [PREVIEW_HEADER]
75
+ } };
66
76
  const routes = {
67
77
  swr: false,
68
78
  cache: false
@@ -1,7 +1,15 @@
1
1
  import { PageEntry } from "./types.mjs";
2
2
  import { MaybeRefOrGetter } from "vue";
3
3
  //#region src/runtime/composables.d.ts
4
- /** An entry, with its Comark document; `data` is null while it loads, `error` a 404 when it is not published. */
4
+ /**
5
+ * An entry, with its Comark document; `data` is null while it loads, `error` a 404 when it is not
6
+ * published. A visitor who opened the admin's "Preview on the site" link has the token in the
7
+ * `hollware_preview` cookie (the server exchanged it for the link's `?hollware-preview=`), and the
8
+ * `/_hollware/entry` route reads the latest revision of that entry instead: a draft too. The page
9
+ * then answers `no-store`; a token that does not hold (expired, of another entry) is an error,
10
+ * never the published version, and when Hollware says it expired or is invalid the response
11
+ * clears the cookie.
12
+ */
5
13
  export declare function useHollwareEntry(collection: MaybeRefOrGetter<string>, locale: MaybeRefOrGetter<string>, slug: MaybeRefOrGetter<string>): import("nuxt/app").AsyncData<{
6
14
  outcome: 'ok';
7
15
  entry: PageEntry;
@@ -1,5 +1,6 @@
1
1
  import { entryTags, groupTags, listTags, recordTags, siteTags } from "./tags.mjs";
2
2
  import { useFetch, useRequestEvent } from "nuxt/app";
3
+ import { appendResponseHeader } from "h3";
3
4
  import { computed, toValue } from "vue";
4
5
  //#region src/runtime/composables.ts
5
6
  /**
@@ -12,15 +13,26 @@ import { computed, toValue } from "vue";
12
13
  function pageRequest() {
13
14
  return import.meta.server ? useRequestEvent() : void 0;
14
15
  }
15
- /** An entry, with its Comark document; `data` is null while it loads, `error` a 404 when it is not published. */
16
+ /**
17
+ * An entry, with its Comark document; `data` is null while it loads, `error` a 404 when it is not
18
+ * published. A visitor who opened the admin's "Preview on the site" link has the token in the
19
+ * `hollware_preview` cookie (the server exchanged it for the link's `?hollware-preview=`), and the
20
+ * `/_hollware/entry` route reads the latest revision of that entry instead: a draft too. The page
21
+ * then answers `no-store`; a token that does not hold (expired, of another entry) is an error,
22
+ * never the published version, and when Hollware says it expired or is invalid the response
23
+ * clears the cookie.
24
+ */
16
25
  function useHollwareEntry(collection, locale, slug) {
17
26
  const path = computed(() => `/_hollware/entry/${encodeURIComponent(toValue(collection))}/${encodeURIComponent(toValue(locale))}/${encodeURIComponent(toValue(slug))}`);
18
27
  const event = pageRequest();
19
28
  return useFetch(path, {
20
- key: path.value,
21
29
  transform: (answer) => {
22
30
  recordTags(event, entryTags(answer.entry));
23
31
  return answer;
32
+ },
33
+ onResponseError: ({ response }) => {
34
+ if (event === void 0) return;
35
+ for (const cookie of response.headers.getSetCookie()) appendResponseHeader(event, "set-cookie", cookie);
24
36
  }
25
37
  });
26
38
  }
@@ -1,5 +1,5 @@
1
- import { HollwareClientError } from "@hollware/client";
2
1
  import { createError, setResponseHeader } from "h3";
2
+ import { HollwareClientError } from "@hollware/client";
3
3
  //#region src/runtime/server/answer.ts
4
4
  /**
5
5
  * Hollware's answer as the site's own: a `not_found` outcome is a 404, a refusal of the read API
@@ -3,7 +3,7 @@
3
3
  * The cache of `hollware.cache: 'purge'` (S8): a response that read Hollware and answered 200 is
4
4
  * kept by Cloudflare's Workers Cache, which serves it again without running the Worker, tagged with
5
5
  * what it showed, until an event of Hollware purges one of its tags (`/_hollware/webhook`). The
6
- * browser keeps nothing: only the edge does (`cloudflare-cdn-cache-control`).
6
+ * browser keeps nothing: only the edge does (`cloudflare-cdn-cache-control`). A preview is never kept.
7
7
  */
8
8
  declare const _default: import("nitropack").NitroAppPlugin;
9
9
  //#endregion
@@ -1,14 +1,16 @@
1
1
  import { edgeHeaders, tagsOf } from "../tags.mjs";
2
+ import { previewTokenFromCookie } from "@hollware/client";
2
3
  import { defineNitroPlugin } from "nitropack/runtime";
3
4
  //#region src/runtime/server/cache.ts
4
5
  /**
5
6
  * The cache of `hollware.cache: 'purge'` (S8): a response that read Hollware and answered 200 is
6
7
  * kept by Cloudflare's Workers Cache, which serves it again without running the Worker, tagged with
7
8
  * what it showed, until an event of Hollware purges one of its tags (`/_hollware/webhook`). The
8
- * browser keeps nothing: only the edge does (`cloudflare-cdn-cache-control`).
9
+ * browser keeps nothing: only the edge does (`cloudflare-cdn-cache-control`). A preview is never kept.
9
10
  */
10
11
  var cache_default = defineNitroPlugin((nitroApp) => {
11
12
  nitroApp.hooks.hook("beforeResponse", (event) => {
13
+ if (previewTokenFromCookie(event.node.req.headers.cookie) !== void 0) return;
12
14
  const headers = edgeHeaders(tagsOf(event), event.node.res.statusCode, event.method);
13
15
  for (const [name, value] of Object.entries(headers ?? {})) event.node.res.setHeader(name, value);
14
16
  });
@@ -0,0 +1,11 @@
1
+ //#region src/runtime/server/preview-exchange.d.ts
2
+ /**
3
+ * The link of the admin's "Preview on the site" (`?hollware-preview=<token>`) is exchanged for a
4
+ * cookie, so the token is in a URL once: a `303` to the same URL without the parameter, with the
5
+ * token in `hollware_preview` (HttpOnly, SameSite=Lax, an hour, Secure but on http://localhost and
6
+ * http://127.0.0.1), `no-store` and `Referrer-Policy: no-referrer`. The token is not checked here:
7
+ * Hollware does, when the page reads. A value not shaped like a token sets no cookie.
8
+ */
9
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<void> | undefined>;
10
+ //#endregion
11
+ export { _default as default };
@@ -0,0 +1,22 @@
1
+ import { requestUrl } from "./url.mjs";
2
+ import { defineEventHandler, sendRedirect, setResponseHeader } from "h3";
3
+ import { exchangeHeaders, exchangePreviewLink } from "@hollware/client";
4
+ //#region src/runtime/server/preview-exchange.ts
5
+ /**
6
+ * The link of the admin's "Preview on the site" (`?hollware-preview=<token>`) is exchanged for a
7
+ * cookie, so the token is in a URL once: a `303` to the same URL without the parameter, with the
8
+ * token in `hollware_preview` (HttpOnly, SameSite=Lax, an hour, Secure but on http://localhost and
9
+ * http://127.0.0.1), `no-store` and `Referrer-Policy: no-referrer`. The token is not checked here:
10
+ * Hollware does, when the page reads. A value not shaped like a token sets no cookie.
11
+ */
12
+ var preview_exchange_default = defineEventHandler((event) => {
13
+ if (event.method !== "GET" && event.method !== "HEAD") return;
14
+ const url = requestUrl(event);
15
+ if (url.pathname.startsWith("/_hollware/")) return;
16
+ const exchange = exchangePreviewLink(url);
17
+ if (exchange === void 0) return;
18
+ for (const [name, value] of exchangeHeaders(exchange, url)) if (name !== "location") setResponseHeader(event, name, value);
19
+ return sendRedirect(event, exchange.location, 303);
20
+ });
21
+ //#endregion
22
+ export { preview_exchange_default as default };
@@ -0,0 +1,12 @@
1
+ //#region src/runtime/server/preview.d.ts
2
+ /**
3
+ * A request that carries a preview token (the `hollware_preview` cookie, see `preview-exchange`)
4
+ * reads the latest revision of an entry, and nothing may keep what it answers: the response is
5
+ * `no-store` and has no cache tag, so neither the browser nor Workers Cache keeps it, and the
6
+ * request gets a header of its own for Nitro's cache (`hollware.cache: <seconds>`) to vary on, so
7
+ * it neither reads nor fills the entry that every other request of the path shares. Any request
8
+ * without a token has no such header, whatever it sent.
9
+ */
10
+ declare const _default: import("nitropack").NitroAppPlugin;
11
+ //#endregion
12
+ export { _default as default };
@@ -0,0 +1,27 @@
1
+ import { PREVIEW_HEADER } from "../tags.mjs";
2
+ import { removeResponseHeader, setResponseHeader } from "h3";
3
+ import { previewTokenFromCookie } from "@hollware/client";
4
+ import { defineNitroPlugin } from "nitropack/runtime";
5
+ //#region src/runtime/server/preview.ts
6
+ /**
7
+ * A request that carries a preview token (the `hollware_preview` cookie, see `preview-exchange`)
8
+ * reads the latest revision of an entry, and nothing may keep what it answers: the response is
9
+ * `no-store` and has no cache tag, so neither the browser nor Workers Cache keeps it, and the
10
+ * request gets a header of its own for Nitro's cache (`hollware.cache: <seconds>`) to vary on, so
11
+ * it neither reads nor fills the entry that every other request of the path shares. Any request
12
+ * without a token has no such header, whatever it sent.
13
+ */
14
+ var preview_default = defineNitroPlugin((nitroApp) => {
15
+ nitroApp.hooks.hook("request", (event) => {
16
+ const headers = event.node.req.headers;
17
+ if (previewTokenFromCookie(headers.cookie) === void 0) delete headers[PREVIEW_HEADER];
18
+ else headers[PREVIEW_HEADER] = crypto.randomUUID();
19
+ });
20
+ nitroApp.hooks.hook("beforeResponse", (event) => {
21
+ if (previewTokenFromCookie(event.node.req.headers.cookie) === void 0) return;
22
+ setResponseHeader(event, "cache-control", "no-store");
23
+ for (const name of ["cache-tag", "cloudflare-cdn-cache-control"]) removeResponseHeader(event, name);
24
+ });
25
+ });
26
+ //#endregion
27
+ export { preview_default as default };
@@ -1,5 +1,5 @@
1
- import { HollwareReader } from "@hollware/client";
2
1
  import { H3Event } from "h3";
2
+ import { HollwareReader } from "@hollware/client";
3
3
  //#region src/runtime/server/reader.d.ts
4
4
  /**
5
5
  * The site's reader of Hollware for a request: the service binding when the Worker has it, else
@@ -1,6 +1,6 @@
1
1
  import { entryTags, groupTags, listTags, recordTags, siteTags } from "../tags.mjs";
2
- import { createClient } from "@hollware/client";
3
2
  import { createError } from "h3";
3
+ import { createClient } from "@hollware/client";
4
4
  import { useRuntimeConfig } from "nitropack/runtime";
5
5
  //#region src/runtime/server/reader.ts
6
6
  function isReader(value) {
@@ -40,7 +40,7 @@ function tagging(event, reader) {
40
40
  ...reader,
41
41
  getEntry: async (...args) => {
42
42
  const answer = await reader.getEntry(...args);
43
- recordTags(event, entryTags(answer.entry));
43
+ if (args[4]?.preview === void 0) recordTags(event, entryTags(answer.entry));
44
44
  return answer;
45
45
  },
46
46
  listEntries: async (...args) => {
@@ -1,11 +1,25 @@
1
1
  import { answer, forPage } from "../answer.mjs";
2
+ import { requestUrl } from "../url.mjs";
2
3
  import { useHollware } from "../reader.mjs";
3
- import { defineEventHandler, getRouterParam } from "h3";
4
+ import { appendResponseHeader, createError, defineEventHandler, getHeader, getRouterParam } from "h3";
5
+ import { clearedPreviewCookie, isPreviewRefusal, isSecureRequest, previewTokenFromCookie } from "@hollware/client";
4
6
  //#region src/runtime/server/routes/entry.ts
5
7
  var entry_default = defineEventHandler(async (event) => {
6
8
  const { site, reader } = useHollware(event);
7
9
  const param = (name) => getRouterParam(event, name) ?? "";
8
- const found = await answer(event, () => reader.getEntry(site, param("collection"), param("slug"), param("locale"), { formats: ["document"] }));
10
+ const preview = previewTokenFromCookie(getHeader(event, "cookie"));
11
+ const found = await answer(event, () => reader.getEntry(site, param("collection"), param("slug"), param("locale"), {
12
+ formats: ["document"],
13
+ ...preview === void 0 ? {} : { preview }
14
+ }).catch((error) => {
15
+ if (preview === void 0 || !isPreviewRefusal(error)) throw error;
16
+ const secure = isSecureRequest(requestUrl(event));
17
+ appendResponseHeader(event, "set-cookie", clearedPreviewCookie({ secure }));
18
+ throw createError({
19
+ statusCode: 404,
20
+ statusMessage: "Not found in Hollware"
21
+ });
22
+ }));
9
23
  return {
10
24
  outcome: found.outcome,
11
25
  entry: forPage(found.entry)
@@ -0,0 +1,8 @@
1
+ import { H3Event } from "h3";
2
+ //#region src/runtime/server/url.d.ts
3
+ /**
4
+ * The URL the visitor asked for. On Workers it is the one of the Request itself, whose scheme and
5
+ * host are the real ones; elsewhere h3 builds it from the `Host` header.
6
+ */
7
+ export declare function requestUrl(event: H3Event): URL;
8
+ //#endregion
@@ -0,0 +1,13 @@
1
+ import { getRequestURL } from "h3";
2
+ //#region src/runtime/server/url.ts
3
+ /**
4
+ * The URL the visitor asked for. On Workers it is the one of the Request itself, whose scheme and
5
+ * host are the real ones; elsewhere h3 builds it from the `Host` header.
6
+ */
7
+ function requestUrl(event) {
8
+ const request = event.context["cloudflare"]?.request;
9
+ const url = typeof request === "object" && request !== null ? Reflect.get(request, "url") : void 0;
10
+ return typeof url === "string" ? new URL(url) : getRequestURL(event);
11
+ }
12
+ //#endregion
13
+ export { requestUrl };
@@ -23,5 +23,11 @@ export declare const EDGE_SECONDS = 31536000;
23
23
  * that read nothing to purge it by. The browser keeps nothing; only the edge does.
24
24
  */
25
25
  export declare function edgeHeaders(tags: readonly string[], status: number, method: string): Readonly<Record<string, string>> | null;
26
+ /**
27
+ * A request header that only the preview plugin sets, and that Nitro's cache (`cache: <seconds>`)
28
+ * varies on: a preview gets a fresh value each time, so it never reads or fills the entry that
29
+ * every other request of the same path shares.
30
+ */
31
+ export declare const PREVIEW_HEADER = "x-hollware-preview";
26
32
  //#endregion
27
33
  export {};
@@ -51,5 +51,11 @@ function edgeHeaders(tags, status, method) {
51
51
  "cloudflare-cdn-cache-control": `max-age=${EDGE_SECONDS}`
52
52
  };
53
53
  }
54
+ /**
55
+ * A request header that only the preview plugin sets, and that Nitro's cache (`cache: <seconds>`)
56
+ * varies on: a preview gets a fresh value each time, so it never reads or fills the entry that
57
+ * every other request of the same path shares.
58
+ */
59
+ const PREVIEW_HEADER = "x-hollware-preview";
54
60
  //#endregion
55
- export { EDGE_SECONDS, edgeHeaders, entryTags, groupTags, listTags, recordTags, siteTags, tagsOf };
61
+ export { EDGE_SECONDS, PREVIEW_HEADER, edgeHeaders, entryTags, groupTags, listTags, recordTags, siteTags, tagsOf };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hollware/nuxt",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Read Hollware from a Nuxt site, over a service binding or HTTP.",
5
5
  "homepage": "https://hollware.com",
6
6
  "bugs": {
@@ -29,8 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@comark/vue": "0.7.0",
32
- "@hollware/client": "0.10.0",
33
- "@hollware/core": "0.10.0",
32
+ "@hollware/client": "0.11.0",
33
+ "@hollware/core": "0.11.0",
34
34
  "@nuxt/kit": "4.6.1"
35
35
  },
36
36
  "devDependencies": {