@parhelia/head
v1.0.12951
Published
Make a rendering head editable in Parhelia, on any framework and without one: the editing bridge, slot markers, the in-place refresh (with a DOM-patching one for heads without a client framework), the rendered-content identity capture reads, the editor si
Readme
@parhelia/head
Make a rendering head editable in Parhelia, on any framework and without one: Next.js, TanStack Start, React Router, Astro, plain server-rendered HTML. Plain JavaScript, no build step, no React: it can be copied into a head that is built elsewhere.
| Entry | What it is for |
|---|---|
| @parhelia/head | the editing bridge (loadParheliaBridge in the browser, parheliaBridgeScriptProps for the server's <script>), the slot markers, the rendered identity, the in-place refresh (installParheliaRefresh, installParheliaDomRefresh), the build values (withBasePath, parheliaOrigin, configureParhelia) |
| @parhelia/head/server | isEditingRequest: is this request the editor? (server-side only) |
| @parhelia/head/identity | the rendered-identity script |
| @parhelia/head/refresh | the in-place refresh loop |
| @parhelia/head/rich-text | sanitizeRichTextHtml for CMS rich text |
| @parhelia/head/picture-crops | a picture's crops and focal point: which crop a variant shows, and the CSS that shows it |
| @parhelia/head/<cms> | a CMS's own helpers: contentful (Live Preview origins, contentfulPictureSources), umbraco (marker names) |
| @parhelia/head/vite | parhelia(), the Vite build adapter |
The framework adapters build on it:
| Package | For |
|---|---|
| @parhelia/react | a React head on any framework: ParheliaBridge, ParheliaBridgeScript, ParheliaSlot, useParheliaRefresh, ParheliaPicture |
| @parhelia/astro | an Astro head: the integration and the slot, bridge and refresh components |
| @parhelia/next | a Next.js head: withParhelia (/config), re-exporting this package and @parhelia/react under the same entries |
The build: base path and editor origin
Two values come from the head's build, because the server render and the browser must agree on them: the base path below
Parhelia's rendering proxy (PARHELIA_RENDERING_PROXY_PATH, /parhelia/proxy/<head name>) and the Parhelia editor's origin
(PARHELIA_ORIGIN, for a head the editor frames directly). withBasePath, ParheliaBridge and
contentfulLivePreviewOrigins read them. A framework adapter puts them into the build:
// vite.config.ts (TanStack Start, React Router, any Vite build): before the framework's own plugin
import { parhelia } from '@parhelia/head/vite';
import { tanstackStart } from '@tanstack/react-start/plugin/vite';
import viteReact from '@vitejs/plugin-react';
export default defineConfig({ plugins: [parhelia(), tanstackStart(), viteReact()] });parhelia() sets Vite's base to the proxy path (assets included; TanStack Start derives its router's base path from it),
inlines the two values into the browser's and the server's code, and bundles this package for the server so the values reach
it. Only these two: the editor secret and the preview key stay server-side environment variables. A Next.js head uses
withParhelia from @parhelia/next/config instead. Any other build calls configureParhelia({ origin, basePath }) in a
module both the server and the browser load.
A directly framed head also lets the editor frame it: add PARHELIA_ORIGIN to the CSP frame-ancestors of the head's
responses (withParhelia does this for Next.js's headers()).
The editing bridge
A head the editor frames directly (its own URL, another origin than the editor's) loads Parhelia's editing bridge itself: a
cross-origin frame cannot be given the bridge by its parent. Below Parhelia's rendering proxy the editor injects it.
loadParheliaBridge() adds the script in the browser, only in a frame of another origin on ?parhelia=1;
parheliaBridgeScriptProps(options) is the <script>'s attributes for a server that renders it into the HTML (a CSP nonce,
integrity), with shouldRenderParheliaBridgeScript({ searchParams | requestUrl }) as the request check. The bridge loads from
/parhelia/editor-bridge/v1/parhelia-bridge.js on the Parhelia origin (origin, default parheliaOrigin()), which is named as
its parent; a bridgeOrigin (a CMS authoring host proxying Parhelia's routes) only serves it. @parhelia/react and
@parhelia/astro wrap both as components.
The editor loads its edit mode and its preview mode with the same request (?parhelia=1, and a CMS preview flag such as
?preview=1), so the head's server cannot tell them apart. The document can: while the editing bridge runs, the root
element names the editor's mode, <html data-parhelia-editor-mode="edit|preview|suggestions">, set as soon as the bridge
runs and updated in place when the person switches modes. Outside the editor's frames it is absent. Scope what only editing
shows (an empty field's hint, the box that makes an empty field clickable) to it, and preview shows the page as visitors see
it:
html[data-parhelia-editor-mode]:not([data-parhelia-editor-mode="preview"]) [data-empty-hint]:empty::before {
content: attr(data-empty-hint);
opacity: 0.45;
}
/* preview: the empty element takes no space, as on the published page */
html[data-parhelia-editor-mode="preview"] [data-empty-hint]:empty {
display: none;
}PARHELIA_EDITING_SELECTOR is that selector (edit and suggestions mode) and PARHELIA_EDITOR_MODE_ATTRIBUTE the attribute,
for CSS-in-JS. The same marker is set on every head that loads the bridge, whatever loads it (ParheliaBridge, Parhelia's
proxy, the Umbraco package).
Empty slots
A slot's rendering (a reference-array field such as a page's topSection) is marked for the editor in Parhelia's frames, also
when it holds nothing, so the canvas offers a drop zone for it: an inert <script> with slotStartAttributes(name,
description) before the slot's content, one with slotEndAttributes(name) after it, and for an empty slot a block with
emptySlotAttributes(name) and a min-height (EMPTY_SLOT_MIN_HEIGHT) between them. ParheliaSlot of @parhelia/react and
@parhelia/astro renders exactly that.
What a render shows: @parhelia/head/identity
Parhelia's capture (agent screenshots, DOM captures, component thumbnails) only captures a document whose renderer states
what it rendered. In Parhelia's frames (?parhelia=1, isParheliaFrameRequest) render one inert script from the data
the page itself read, never from the request's query:
// getServerSideProps (or a server component): the entry this render shows, by its own id
import { parheliaStore, renderedIdentityScriptProps } from '@parhelia/head/identity';
const identity = { store: parheliaStore(), itemId: page.sys.id, language: locale };
// the page
<script {...renderedIdentityScriptProps(identity)} />| Part | Value |
|---|---|
| store | the Parhelia store the head renders: PARHELIA_STORE (e.g. contentful:<space>/<concrete environment>) |
| itemId | the item's id as its CMS spells it: a Contentful entry's sys.id, a Sitecore item's Guid |
| language | the locale rendered |
| version | the version rendered where the CMS keeps numbered versions; omitted for Contentful |
A pages-router head can let frameRenderedIdentity do the gating: it answers the identity in Parhelia's frames and null
elsewhere, when the render produced no item, or when PARHELIA_STORE is unset (the server log says so once):
// getServerSideProps
import { frameRenderedIdentity } from '@parhelia/head';
return { props: { ..., parheliaIdentity: frameRenderedIdentity(query, { itemId: page?.sys.id, language: locale }) } };
// _app.tsx
{pageProps.parheliaIdentity && <script {...renderedIdentityScriptProps(pageProps.parheliaIdentity)} />}Contentful's marketing starter, made editable in Parhelia, does exactly this. Settings ›
Rendering Hosts › the head's settings lists the PARHELIA_STORE line for a head of a Contentful mount.
The module imports nothing (no React, no Next.js), so a head that links this package from a checkout never resolves a
second React through it; renderedIdentityScriptHtml writes the same script as HTML. Without the script a capture is
refused with "the rendered document did not confirm the exact store, item, locale, and version".
Is this the editor? @parhelia/head/server
?parhelia=1 says which editor request a request is, never whether it is one: a visitor can type it. A head's server
decides with isEditingRequest before it renders markers, empty components or the identity script:
// getServerSideProps (Pages Router); a route handler or server component passes the Request / headers() and searchParams
import { isEditingRequest } from '@parhelia/head/server';
import { readPreviewSessionFor } from '@parhelia/preview-ticket/next';
const session = await readPreviewSessionFor(req, res);
const edit = isEditingRequest(req, query, session !== null);A fetch-handler server (TanStack Start, React Router, Hono) passes its Web Request and reads the session with
@parhelia/preview-ticket/fetch:
import { isEditingRequest } from '@parhelia/head/server';
import { readPreviewSession } from '@parhelia/preview-ticket/fetch';
const session = await readPreviewSession(request);
const edit = isEditingRequest(request, query, session !== null);It is true for one of Parhelia's frames (parhelia=1, which every edit URL the editor builds carries) that either carries the rendering proxy's
X-Parhelia-Editor header equal to PARHELIA_EDITOR_SECRET (the host entry's editorSecret=; the proxy drops whatever the
browser sent under that name) or has a live per-person preview session (@parhelia/preview-ticket). No secret configured and
no session: never the editor. hasEditorSignal(request) and editorSignalAccepted(value, secret) are the parts; the
comparison is constant-time and runs on Node and the Edge alike.
CMS rich text: @parhelia/head/rich-text
Below Parhelia's rendering proxy the head is served from the editor's origin, so a script, event handler or javascript: link
in a stored rich-text value would run with the editing person's session. Write CMS rich text only through
sanitizeRichTextHtml:
import { sanitizeRichTextHtml } from '@parhelia/head/rich-text';
<div {...fieldMarkers} dangerouslySetInnerHTML={{ __html: sanitizeRichTextHtml(storedHtml) }} />;It keeps formatting (headings, paragraphs, lists, tables, links, images, quotes, a fixed set of style properties) and Parhelia's
own placeholder and internal-link markers; it removes scripts, styles, frames, objects, forms' lists, SVG and MathML with their
content, every event handler, other data-* attributes, URLs other than http(s), mailto, tel (an image also raster
data:image/…, a link also parhelia:), and ids that would shadow a page global. It needs no DOM (a server component,
getServerSideProps) and no dependency. The editing bridge applies the same rule (richTextElementAction,
sanitizeRichTextAttribute) to the values it writes into the canvas.
Picture crops: @parhelia/head/picture-crops
A Contentful entry whose crops field uses the Parhelia app keeps its crops beside the asset link, as JSON (v: 1): the
image it was made on, a focal point, and the variants someone cropped by hand, in fractions of the original. The image service
cannot cut out a rectangle, so a head shows a crop with CSS:
import { cropFor, cropStyle, readPictureCrops, requestWidth } from '@parhelia/head/picture-crops';
const read = readPictureCrops(entry.fields.crops);
const image = { asset: asset.sys.id, file: file.url, size: file.details.image };
const shown = cropFor(read, { name: 'landscape', ratio: '16:9' }, image); // the stored crop, else one around the focal point
const style = shown && cropStyle(shown.crop, image.size); // the box's aspect ratio, the image's size and offset in it
const width = shown && requestWidth(800, shown.crop, image.size, 2); // the `w` to ask the Images API forThe box clips (position: relative; overflow: hidden; aspect-ratio: <style.aspectRatio>); the image inside is
position: absolute; max-width: none with style.width, style.height, style.left and style.top. Put data-variant and
data-parhelia-crop (cropAttribute(shown.crop)) on the image, so the editor knows which part of it the visitor sees.
contentfulPictureSources (@parhelia/head/contentful) does all of that for a <picture> whose variants switch by media query:
import { contentfulPictureSources } from '@parhelia/head/contentful';
const picture = contentfulPictureSources(
{ id: asset.sys.id, url: file.url, ...file.details.image },
entry.fields.crops,
[
{ name: 'square', ratio: '1:1', media: '(max-width: 61.99em)', sizes: '50vw' },
{ name: 'landscape', ratio: '16:9', sizes: '33vw' },
],
);It answers the picture's CSS (render it in a <style> as raw text; React sets it with dangerouslySetInnerHTML, as a child
its server rendering escapes it), the clipping box's key attribute (box; put the field's annotations on the box too), and
each <source> and the <img> with its Images API srcset, sizes, size, variant and crop. The box also gets
--parhelia-crop-ratio, so a site that caps its height can cap its width at the same ratio. options.focalPoint (fractions) is
the point the automatic crops follow where the stored value names none for this file: a head with a focal point of its own
keeps it as the fallback. A React head renders it with ParheliaPicture (@parhelia/react).
In-place refresh
The editor calls window.requestRefresh(newUri?) after a save. installParheliaRefresh({ refetch, signature, deadlineMs })
installs it: refetch reads the page again the head's way, signature() is a stable string of what the page rendered, and the
loop reads again once a second until it changed and settled, or the deadline passed. The answer removes it again.
useParheliaRefresh of @parhelia/react is the React hook around it. A head without a client framework uses
installParheliaDomRefresh(): it reads the page again and patches what changed into the live document, keeping every node the
new render left alone (and what the editing bridge added to it), inside the element marked data-parhelia-refresh-root;
ParheliaRefresh of @parhelia/astro renders that element and installs it.
