Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/web/content/docs/content-engine/access-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ Mounted under the scope family's admin module, `/api/{pluginId}/admin/content/{a
| `GET /access/files` | `can_view` | `{ privateStorage, private, public }`: the family's files by visibility |
| `POST /access/files/protect` | `can_edit` | Moves up to 100 public files to private storage: `{ moved, remaining, shared }` |

A content type filed under scopes gets `POST /access/effective` in its own module, such as `/api/@vitnode/blog/admin/content/posts/access/effective`. It takes `{ scopeIds }` (up to 50) and answers what readers need for each action across all of them, plus `missing` scope ids. `ContentAccessEffectiveNotice` from `@vitnode/core/views/admin/views/access/effective-notice` renders it in an editor.
A content type filed under scopes gets `POST /access/effective` in its own module, such as `/api/@vitnode/blog/admin/content/posts/access/effective`. It takes `{ scopeIds }` (up to 50) and answers what readers need for each action across all of them, plus `missing` scope ids. `ContentAccessEffectiveNotice` from `@vitnode/core/views/admin/views/access/effective-notice` renders it in an editor as a **Public** or **Restricted** badge; click it for who can view, read, comment and download.

`overrides` is a partial `Record<action, audience>`. An action left out inherits. Call these routes with `rawFetcher`:

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
88 changes: 87 additions & 1 deletion apps/web/content/docs/content-engine/editorial-previews.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ icon: Eye
---

import { ImgDocs } from "@/components/fumadocs/img";
import livePreview from "./editorial-live-preview.webp";
import previewLink from "./editorial-preview-link.webp";

A preview link lets a reviewer read a draft without signing in. It needs [`editorial`](/docs/content-engine/editorial) and a [public API](/docs/content-engine/public-api), and a page that knows how to load the draft. The examples use `newsroom.story` from the newsroom plugin.
Expand Down Expand Up @@ -34,7 +35,7 @@ In the AdminCP, open **More actions** and choose **Preview**. Creating a link ne
alt="Preview link dialog with a signed link to the Spring menu launches Monday story at /stories/spring-menu-launches-monday, a copy button, an Open button and an Expires in 30 minutes note"
/>

The link is HMAC-signed, expires after `expiresInMinutes` and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts.
The link is HMAC-signed, expires after `expiresInMinutes` and cannot be revoked early, so treat it like a password. It is pinned to the last saved version, so it shows what **Save** committed, not unsaved [live editing](/docs/content-engine/live-editing) drafts. Want those too? Use the [live preview](#live-preview).

The link points to the first of these that exists:

Expand All @@ -58,3 +59,88 @@ The route returns the record's public fields, and the same `404` for a forged, e

VitNode builds links from `VITNODE_WEB_URL` and `VITNODE_API_URL`. When either is not an absolute URL, the API logs a warning at startup and the **Preview** button answers `503`.

## Live preview

The live preview puts the real page next to the form, and it updates as you type, before anything is saved. In the blog's article editor, click **Live preview** (the eye icon) in the header, and the page slides in from the right:

<ImgDocs
withoutBackground
className="*:max-w-full"
src={livePreview}
alt="Blog article editor split in two: the form with the cover, title and categories on the left, and the article as readers see it on the right, under a toolbar with screen size buttons, reload, open in a new tab and close"
/>

- The screen size buttons switch between **Fit to panel**, **Mobile**, **Tablet** and **Desktop**. Fixed sizes shrink to fit the panel, so a desktop page still fits on a laptop.
- **Open in a new tab** pops the preview out, and that tab keeps following your edits.
- On phones, the preview takes the whole screen. Click the eye again to get back to the form.

### Add it to your editor

`ContentLivePreview` comes from `@vitnode/core/content/admin-form`. Render it in your [custom form layout](/docs/content-engine/admincp):

```tsx title="A custom form layout (excerpt)"
import type { ContentFormLayoutProps } from "@vitnode/core/lib/plugin";

import { Button } from "@vitnode/core/components/ui/button";
import { ContentLivePreview } from "@vitnode/core/content/admin-form";
import { EyeIcon } from "lucide-react";
import React from "react";

export const StoryFormLayout = ({
contentTypeId,
itemId,
}: ContentFormLayoutProps) => {
const [open, setOpen] = React.useState(false);

return (
<>
<Button
aria-pressed={open}
onClick={() => {
setOpen(current => !current);
}}
type="button"
variant="ghost"
>
<EyeIcon aria-hidden />
Live preview
</Button>
{open && itemId !== undefined ? (
<ContentLivePreview
className="h-dvh"
contentTypeId={contentTypeId}
itemId={itemId}
locale="en"
onClose={() => {
setOpen(false);
}}
/>
) : null}
</>
);
};
```

`ContentLivePreview` creates a preview link for `locale` (falling back to the default language when that translation was never saved), opens it in an iframe and sends the unsaved form values to it with `postMessage`. It renews the link before it expires, so a long writing session doesn't end in a 404.

### Follow the edits in your page

The page behind the link reads the values with `useContentLivePreview` from `@vitnode/core/tanstack/content` and lays them over the record it loaded:

```tsx title="The preview page (excerpt)"
import type { MultiLangValue } from "@vitnode/core/lib/helpers/multi-lang";

import { getLangValue } from "@vitnode/core/lib/helpers/multi-lang";
import { useContentLivePreview } from "@vitnode/core/tanstack/content";

const StoryTitle = ({ saved }: { saved: string }) => {
const live = useContentLivePreview();
const values = live?.values as { title?: MultiLangValue } | undefined;

return <h1>{getLangValue(values?.title, live?.locale ?? "en") || saved}</h1>;
};
```

The values come exactly as the AdminCP form holds them: a localized field is `{ languageCode, value }[]`, and relations, users and files are ids. For users and files, `live.references.people` and `live.references.files` hold the full objects, including an author picked or a cover uploaded a second ago, so look each id up there first, then in the record the page loaded. Relations stay ids, so map those to the objects the page already loaded. The hook returns `null` until the editor sends something, which is all it ever returns when someone opens the link on its own. It only reads messages from the same origin, sent by the window that embeds or opened the page.

The blog does this in `plugins/blog/src/pages/post-preview-page.tsx`, with `pathTemplate: "/blog/preview/{token}"`.
10 changes: 9 additions & 1 deletion apps/web/src/locales/@vitnode/blog/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,11 @@
"url_missing": "Brak adresu URL w tym języku.",
"cover_cropped": "Obraz wyróżniający przycięty do kart społecznościowych.",
"alt_edit": "Edytuj pod obrazem"
},
"shared_fields": "Takie samo w każdym języku",
"live_preview": {
"open": "Podgląd na żywo",
"close": "Ukryj podgląd na żywo"
}
}
},
Expand Down Expand Up @@ -179,7 +184,10 @@
"by": "Autorzy:",
"edited": "Edytowano",
"reading_time": "{minutes} min czytania",
"on_this_page": "Na tej stronie"
"on_this_page": "Na tej stronie",
"preview": {
"title": "Podgląd: {title}"
}
},
"list": {
"title": "Blog",
Expand Down
7 changes: 7 additions & 0 deletions packages/vitnode/src/content/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,13 @@ export {
resolveContentTranslationIndexes,
toSnakeCase,
} from "./indexes";
export {
CONTENT_LIVE_PREVIEW_MESSAGE,
type ContentLivePreviewPayload,
type ContentLivePreviewReferences,
isContentLivePreviewReady,
parseContentLivePreviewValues,
} from "./live-preview";
export type {
ContentRefreshClientMessage,
ContentRefreshErrorCode,
Expand Down
92 changes: 92 additions & 0 deletions packages/vitnode/src/content/live-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { z } from "zod";

import type { ContentFileDescriptor } from "./files";
import type { ContentPublicPerson } from "./public-person";

import { zodContentFileDescriptor } from "./files";
import { zodContentPublicPerson } from "./public-person";

/**
* The `type` of every live-preview `postMessage`. The preview page sends
* `ready` to its framing or opening editor when it loads; the editor answers
* with `values` on that and on every form change.
*/
export const CONTENT_LIVE_PREVIEW_MESSAGE = {
ready: "vitnode:content-live-preview:ready",
values: "vitnode:content-live-preview:values",
} as const;

/**
* The descriptors behind the identifiers in `values`, so a preview can render a
* cover image just uploaded or an author just picked without a lookup of its
* own. Each list holds every descriptor the editor knows, saved or not.
*/
export interface ContentLivePreviewReferences {
files: ContentFileDescriptor[];
people: ContentPublicPerson[];
}

/** One message from the editor: the unsaved form, as the form holds it. */
export interface ContentLivePreviewPayload {
/** The language the editor is writing in. */
locale: string;
references: ContentLivePreviewReferences;
/**
* Every field by name, in the form's own shapes: a file or user field holds
* identifiers (look them up in `references`), a localized field holds
* `{ languageCode, value }[]`.
*/
values: Record<string, unknown>;
}

const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);

const zodReferences = z.object({
files: z.array(zodContentFileDescriptor).catch([]),
people: z.array(zodContentPublicPerson).catch([]),
});

/**
* Reads a `values` message (`event.data`) sent by the editor's
* `ContentLivePreview`. Universal and pure.
*
* Accepts `{ type: CONTENT_LIVE_PREVIEW_MESSAGE.values, locale: string,
* values: object, references?: { files, people } }`. Returns `null` for any
* other type, a missing `locale` or a `values` that is not a plain object.
* Invalid `references` lists become empty lists instead of rejecting the
* message.
*
* Only the envelope is checked: each entry in `values` stays `unknown`, so
* validate a field before you render it. It does not authenticate the
* message either - check `event.origin` and `event.source` first, as
* `useContentLivePreview` does.
*/
export const parseContentLivePreviewValues = (
data: unknown,
): ContentLivePreviewPayload | null => {
Comment on lines +65 to +67

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the exported live-preview protocol helpers

The new parseContentLivePreviewValues and isContentLivePreviewReady helpers are re-exported through @vitnode/core/content, but neither has declaration-level JSDoc. Document their universal runtime, accepted message shapes, return semantics, and validation limits—particularly that parsing does not authenticate the message's origin or source. The hook and component now have documentation, but these additional public protocol APIs still violate the declaration-documentation requirement in AGENTS.md.

AGENTS.md reference: AGENTS.md:L134-L135

Useful? React with 👍 / 👎.

if (!isRecord(data) || data.type !== CONTENT_LIVE_PREVIEW_MESSAGE.values) {
return null;
}
if (typeof data.locale !== "string" || !isRecord(data.values)) return null;

return {
locale: data.locale,
references: zodReferences
.catch({ files: [], people: [] })
.parse(data.references),
values: data.values,
};
};

/**
* Whether `event.data` is the `ready` message a preview page sends when it
* loads, telling the editor to send the current values. Universal and pure.
*
* Accepts any object whose `type` is `CONTENT_LIVE_PREVIEW_MESSAGE.ready`;
* other properties are ignored. It does not authenticate the message - check
* `event.origin` and that `event.source` is the preview frame or popup the
* editor opened before answering.
*/
export const isContentLivePreviewReady = (data: unknown): boolean =>
isRecord(data) && data.type === CONTENT_LIVE_PREVIEW_MESSAGE.ready;
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ describe("the generated permission matrix", () => {
"DELETE /{id}": "can_delete",
"DELETE /{id}/translations/{locale}": "can_delete",
"GET /": "can_view",
"GET /live-preview/people": "can_view",
"GET /options/{field}": "can_view",
"GET /{id}": "can_view",
// Read-only: it reports what the slug mutations already did, so the
Expand Down
43 changes: 42 additions & 1 deletion packages/vitnode/src/content/server/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { AnyContentModel, ContentModel } from "./model";
import type { ContentPreviewTarget } from "./preview-target";

import { checkStaffPermission } from "../../api/lib/check-staff-permission";
import { loadPublicPeople, zodPublicPerson } from "../../api/lib/public-people";
import { buildRoute } from "../../api/lib/route";
import {
zodPaginationPageInfo,
Expand Down Expand Up @@ -1671,6 +1672,46 @@ export const buildContentRoutes = <
* a table of 25 rows must not be 25 live bearer tokens for unpublished
* records sitting in a browser's memory.
*/
const livePreviewPeople = buildRoute({
pluginId,
adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view },
route: {
method: "get",
path: "/live-preview/people",
description: `Public summaries of the people a ${name} live preview names`,
request: {
query: z.object({
ids: z.string().optional(),
}),
},
responses: {
200: jsonResponse(
z.object({ items: z.array(zodPublicPerson) }),
"The people found, in the order asked",
),
},
},
handler: async c => {
const ids = (c.req.valid("query").ids ?? "")
.split(",")
.map(Number)
.filter(id => Number.isSafeInteger(id) && id > 0)
.slice(0, CONTENT_OPTIONS_LIMIT);
const people = await loadPublicPeople(c, ids);

return c.json(
{
items: ids.flatMap(id => {
const person = people.get(id);

return person ? [person] : [];
}),
},
200,
);
},
});

const previewToken = buildRoute({
pluginId,
adminStaffPermission: { module, permission: CONTENT_PERMISSIONS.view },
Expand Down Expand Up @@ -2101,7 +2142,7 @@ export const buildContentRoutes = <
// Mounted only for a content type that declares a file field, so nothing
// else gains a binary endpoint it has no use for.
...(hasFileFields ? [upload] : []),
...(previewEnabled ? [previewToken] : []),
...(previewEnabled ? [previewToken, livePreviewPeople] : []),
...(definition.delivery.enabled ? [deliveryDetail] : []),
...(definition.editorial.scheduling.enabled
? [scheduleList, scheduleCreate, scheduleCancel]
Expand Down
24 changes: 23 additions & 1 deletion packages/vitnode/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1992,6 +1992,26 @@
"unavailable": "Preview links cannot be built on this deployment. Check that VITNODE_WEB_URL and VITNODE_API_URL are absolute URLs, then restart the API.",
"live": "This record has no saved version yet, so the link shows it live - it will follow any edits made before the reviewer opens it."
},
"live_preview": {
"title": "Live preview",
"loading": "Creating a preview link…",
"frame": "Live preview of the page",
"device": "Screen size",
"devices": {
"responsive": "Fit to panel",
"mobile": "Mobile",
"tablet": "Tablet",
"desktop": "Desktop"
},
"size": "{width} × {height}",
"reload": "Reload preview",
"open": "Open in a new tab",
"close": "Close preview",
"load_error": {
"title": "Preview didn't load",
"desc": "We couldn't create a preview link for this page. Try again in a moment."
}
},
"delivery": {
"title": "Delivery for this {name}",
"desc": "Where this record lives on the public site, and where it used to.",
Expand Down Expand Up @@ -3238,7 +3258,9 @@
},
"notice": {
"title": "Who can see this",
"open": "Everyone can read this, comment on it and download its attachments - none of its categories restricts it.",
"label": "Who can see this:",
"public": "Public",
"restricted": "Restricted",
"and": ", and also ",
"intersection": "A reader has to meet the rule of every category it is in, so two restricted categories together let in fewer people than either one alone.",
"missing": "One of the selected categories no longer exists. Nobody can read a record filed there."
Expand Down
12 changes: 10 additions & 2 deletions packages/vitnode/src/tanstack/admin/content/editorial/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getContentRevisionInBrowser,
listContentRevisionsInBrowser,
listContentSchedulesInBrowser,
loadContentLivePreviewPeopleInBrowser,
readContentDeliveryInBrowser,
restoreContentRevisionInBrowser,
scheduleContentInBrowser,
Expand All @@ -36,8 +37,12 @@ export const contentEditorialTransport = (
scheduleId,
),

createPreview: async (contentTypeId, itemId) =>
await createContentPreviewInBrowser(targetFor(contentTypeId), itemId),
createPreview: async (contentTypeId, itemId, locale) =>
await createContentPreviewInBrowser(
targetFor(contentTypeId),
itemId,
locale,
),

getRevision: async (contentTypeId, itemId, revisionId) =>
await getContentRevisionInBrowser(
Expand All @@ -46,6 +51,9 @@ export const contentEditorialTransport = (
revisionId,
),

livePreviewPeople: async (contentTypeId, ids) =>
await loadContentLivePreviewPeopleInBrowser(targetFor(contentTypeId), ids),

listRevisions: async (contentTypeId, itemId, cursor) =>
await listContentRevisionsInBrowser(
targetFor(contentTypeId),
Expand Down
Loading
Loading