Skip to content

Latest commit

 

History

History
267 lines (193 loc) · 17.6 KB

File metadata and controls

267 lines (193 loc) · 17.6 KB

@localess/astro Reference

Astro integration layer for Localess. A full Astro Integration (localess() in astro.config.mjs), matching @storyblok/astro's architecture — see ADR 006 for why and how it differs where Localess's constraints require it.

Peer dependency: Astro 6 or 7 (astro@^6.0.0 || ^7.0.0). Node.js: >= 24. Package version 4.0.2, in lockstep with the other @localess/* packages.

Installation

npm install @localess/astro

Configuration

// astro.config.mjs
import { defineConfig } from 'astro/config';
import { localess } from '@localess/astro';

export default defineConfig({
  integrations: [
    localess({
      origin: process.env.LOCALESS_ORIGIN,
      spaceId: process.env.LOCALESS_SPACE_ID,
      token: process.env.LOCALESS_TOKEN,
      enableSync: true,
    }),
  ],
});

localess() (alias localessIntegration()) takes LocalessOptions (src/models/client.ts) — @localess/client's LocalessClientOptions plus Astro-specific settings:

Option Type Default Purpose
origin string — Localess instance URL (protocol + host + port).
spaceId string — Space ID. Not a secret — also used to validate live-preview requests.
token string — API token. Secret — see below.
version 'draft' published Fetch the latest draft instead of published content.
debug boolean false Client debug logging; also turns on the Visual Editor sync script's snackbars and console logs.
cacheTTL number | false 300 Client response cache TTL in seconds; false disables caching.
timeoutMs number | false 15000 Per-attempt request timeout in ms; false disables it.
retry LocalessRetryOptions | false { attempts: 3, baseDelayMs: 300, maxDelayMs: 5000 } Retry policy for failed requests; false disables retrying.
fetchInit LocalessFetchInit — Merged into every fetch call; bypasses the client cache. (fetch and cache are not accepted — they can't survive the options' JSON.stringify into virtual:localess-init.)
componentsDir string 'src' Directory scanned for schema components (<componentsDir>/**/*.astro).
componentNaming ComponentNamingStrategy 'exact' How registry keys and _schema are normalized before matching. Strategy names only, no custom function.
components schema key → component path (relative to componentsDir) — Explicit map merged with auto-discovery. See "Component registry".
enableFallbackComponent boolean false Render a fallback component for unknown schema keys instead of throwing.
customFallbackComponent string built-in FallbackComponent.astro Path (relative to componentsDir) to your own fallback component.
enableSync boolean false Reload-on-save Visual Editor sync. Ignored when livePreview is true.
livePreview boolean false SSR-only live-patching Visual Editor sync. Requires output: 'server'.

Token handling

token is secret-only. @localess/astro has not been reworked for Localess's public tokens (ADR 001) — never pass a token into client-side code or suggest a browser-side client for this package. The integration keeps the token server-side: the LocalessClient is built in a virtual:localess-init module that is injected only via Astro's page-ssr script stage, and the token is never written into virtual:localess-options (which .astro frontmatter and the live-preview middleware read). Only origin and spaceId reach browser-side scripts (for loadLocalessSync(origin) and window.__localessSpaceId). Under static output, the token is only read at build time.

Rendering content

---
import { getLocalessClient } from '@localess/astro';
import LocalessDocument from '@localess/astro/LocalessDocument.astro';

const content = await getLocalessClient().getContentBySlug('home');
---

<LocalessDocument document={content} />

getLocalessClient() returns the LocalessClient the integration built from your astro.config.mjs options (it throws if the integration isn't configured). LocalessDocument takes a single prop, document: Content, and throws if document.data is missing; it renders document.data through LocalessComponent, forwarding document.links, document.references, and document.assets.

To render a nested block directly (e.g. inside a custom component), use LocalessComponent:

---
import LocalessComponent from '@localess/astro/LocalessComponent.astro';
---

{data.body.map(item => <LocalessComponent data={item} links={links} references={references} assets={assets} />)}

LocalessComponent's props are LocalessComponentProps: data: ContentData (required — throws if missing), optional links: Links, references: References, assets: Assets, plus any extra props. It resolves the component registered for data._schema, normalized through the componentNaming strategy (falling back to the fallback component when enabled, otherwise throwing) and renders it with data/links/references/assets, the localessEditable(data) attributes (data-ll-id, data-ll-schema), and the extra props spread onto it.

Component registry

Components auto-register from <componentsDir>/**/*.astro (default componentsDir: 'src', so src/**/*.astro), keyed by file name — under the default componentNaming: 'exact', HeroSection.astro registers as HeroSection. Merge in an explicit map via the components option; values are component paths relative to componentsDir (the .astro extension is optional):

localess({
  // ...
  componentsDir: 'src/components/localess',
  components: { 'hero-section': 'sections/Hero' }, // resolves src/components/localess/sections/Hero.astro
});

A mapped path that doesn't resolve throws at build time, unless enableFallbackComponent is true, in which case the entry is skipped. Both the registry key and _schema are compared through the componentNaming strategy — under the default 'exact' they must match verbatim; set componentNaming: 'camelCase' (or another strategy) so a file named HeroSection.astro matches _schema: 'hero-section'.

Writing components

Type a registered component's Props with LocalessSchemaProps<T> — the same generic shape @localess/react's LocalessSchemaProps<T> uses (LocalessComponentProps is the built-in renderer's own props type, not the schema-component contract):

---
import { localessEditable, localessEditableField } from '@localess/astro';
import type { LocalessSchemaProps } from '@localess/astro';
import type { HeroSection } from './.localess/localess'; // your generated content type

export type Props = LocalessSchemaProps<HeroSection>;

const { data } = Astro.props;
---

<section {...localessEditable(data)}>
  <h1 {...localessEditableField<HeroSection>('title')}>{data.title}</h1>
</section>

localessEditable(data) emits data-ll-id/data-ll-schema; localessEditableField(name) emits data-ll-field. Both are harmless when sync is off. LocalessComponent already spreads localessEditable(data) onto your component, so you only need it yourself when your root element doesn't receive those props.

Fallback component

localess({
  // ...
  enableFallbackComponent: true,
  customFallbackComponent: 'localess/CustomFallback', // optional, relative to componentsDir; omit to use the built-in FallbackComponent.astro
});

The built-in FallbackComponent.astro (also importable from @localess/astro/FallbackComponent.astro) accepts LocalessSchemaProps and renders a "Component could not be found for schema …" notice. A custom fallback must accept the same props; if its path doesn't resolve the build throws.

Visual Editor sync

Two tiers, mutually exclusive (livePreview wins when both are set):

  • enableSync: true — loads the Localess sync script from origin (click-to-select, hover outlines), then on save/publish/unpublish debounces (~500ms) and reloads the page. It deliberately ignores input/change: the editor sends a change on every connect, so reloading on it loops forever, and a reload cannot show unsaved edits anyway — use livePreview for that. Works under both SSR and static output, but under static output a reload only shows new content in astro dev; a production static build keeps serving its prerendered HTML, so there it provides click-to-select only.
  • livePreview: true — SSR-only (output: 'server'; the integration throws at config-setup time otherwise). save/publish/unpublish reload; input/change debounce (~500ms), POST the updated content to the current page, and morphdom-patch the response into the live DOM, keyed by data-ll-id (preserving the sync script's snackbar container and the editor's ll-hover-highlight class, which the server HTML knows nothing about). The integration wires this up for you: it injects a page script that calls handleLocalessMessage and registers live-preview/middleware.ts as @localess/astro/middleware (order: 'pre'), which accepts a preview POST only when it is same-origin (Sec-Fetch-Site: same-origin) and its body's spaceId matches the one passed to localess().

With livePreview, fetch the content in page frontmatter, then overlay the draft from getLivePayload(Astro, documentId). The preview POST body carries the edited documentId, and getLivePayload returns the draft only when it matches the id you pass — so pass the rendered document's id, and a page rendering several documents (or one reached by clicking a link inside the preview) won't pick up another document's edits. The payload carries only data (no links/references/assets), and is empty on ordinary requests:

---
import { getLivePayload, getLocalessClient } from '@localess/astro';
import LocalessComponent from '@localess/astro/LocalessComponent.astro';

const content = await getLocalessClient().getContentBySlug('home');
const preview = await getLivePayload(Astro, content.id);
const data = preview.data ?? content.data;
---

<LocalessComponent data={data} links={content.links} references={content.references} assets={content.assets} />

Dev toolbar

The integration always registers a "Localess" Astro dev-toolbar app (src/dev-toolbar/toolbar-app.ts, entrypoint @localess/astro/toolbarApp) with links to the Localess docs and the issue tracker. It has no configuration.

Rich text

---
import LocalessRichText from '@localess/astro/LocalessRichText.astro';
---

<LocalessRichText content={data.body} />

Props: content: LocalessRichTextInput (a rich text document, node, or node array — ContentRichText values fit), and optional renderers. Built on @localess/richtext (see docs/richtext.md) — no TipTap at runtime. Supported elements: headings 1–6, paragraphs, bold/italic/strike/underline/code, ordered/unordered lists, code blocks, blockquotes, horizontal rules, links. Per-node customization via the string-based renderers prop; each renderer receives the node (with attrs) plus pre-rendered children HTML:

<LocalessRichText content={data.body} renderers={{ paragraph: ({ children }) => `<p class="prose">${children}</p>` }} />

renderRichTextToHtml(content, { renderers? }) and its alias renderLocalessRichTextToHtml remain available from @localess/astro for standalone rendering. Link hrefs pass a protocol allowlist (javascript:/data: become empty); unknown node types are skipped with a warning (silenced when NODE_ENV === 'production') unless a renderer for that type is provided. A custom link renderer receives an already-sanitized attrs.href, but a string renderer must still escape what it interpolates: use escapeAttr for attribute values and escapeHtml for text (both re-exported, with sanitizeUrl).

Assets

---
import { resolveAsset } from '@localess/astro';
---

<img src={resolveAsset(data.heroImage, { w: 800 })} alt={data.title} />

resolveAsset(asset: ContentAsset, params?: AssetTransformParams) delegates to the client's assetLink. A ContentAsset is { kind: 'ASSET', uri } — it carries no alt text; take that from another field. Transform params include w, h, q, f, fit, thumbnail — fit controls the w+h behaviour, where the API default cover crops and inside shrinks to fit (see docs/client.md). For the uploaded bytes use resolveAssetOriginal(asset) (inline) or resolveAssetDownload(asset) (attachment); neither takes params. resolveAsset(asset) with no params does not return the uploaded file — a still raster is re-encoded at its format default quality. download and f: 'original' were removed in v4.

Import paths — subpath exports required for .astro files

LocalessComponent, LocalessDocument, LocalessRichText, FallbackComponent are .astro files and cannot be imported from @localess/astro's default entry point. Import them (default export) from their dedicated subpaths:

import LocalessComponent from '@localess/astro/LocalessComponent.astro';
import LocalessDocument from '@localess/astro/LocalessDocument.astro';
import LocalessRichText from '@localess/astro/LocalessRichText.astro';
import FallbackComponent from '@localess/astro/FallbackComponent.astro';

Everything else imports from the default entry point (@localess/astro) — never from @localess/client or @localess/richtext directly:

  • Integration and helpers: localess/localessIntegration, getLocalessClient, getLivePayload, resolveAsset, resolveAssetOriginal, resolveAssetDownload, handleLocalessMessage, normalizeComponentKey, toCamelCase (deprecated), renderRichTextToHtml/renderLocalessRichTextToHtml, and the string-renderer helpers escapeHtml/escapeAttr/sanitizeUrl.
  • Browser-safe sync utilities: loadLocalessSync, localessEditable, localessEditableField, isBrowser, isIframe.
  • Errors: LocalessApiError.
  • localessClient — server-only factory, re-exported for the integration's generated virtual:localess-init module; in app code use getLocalessClient() instead.
  • Types: LocalessOptions, LocalessComponentProps, LocalessSchemaProps, LocalessClient, LocalessClientOptions, ComponentNamingStrategy, LocalessSync, EventToApp, EventToAppOf, EventToAppType, EventCallback; model types Content, ContentData, ContentDataSchema, ContentDataField, ContentMetadata, ContentAsset, ContentLink, ContentReference, ContentRichText, Assets, AssetMetadata, AssetTransformParams, Links, References; rich text types LocalessRichTextInput, LocalessRichTextDocument, LocalessRichTextNode, LocalessRichTextMark.

The @localess/astro/middleware and @localess/astro/toolbarApp subpaths exist for the integration's own addMiddleware/addDevToolbarApp entrypoints — you don't import them yourself.

Error handling

getContentBySlug/getContentById throw LocalessApiError on a non-2xx API response — check error.status === 404 to distinguish a missing slug from other failures (network errors, 5xx) and render your own not-found page:

---
import { getLocalessClient, LocalessApiError } from '@localess/astro';

let content;
try {
  content = await getLocalessClient().getContentBySlug(slug);
} catch (error) {
  if (error instanceof LocalessApiError && error.status === 404) {
    Astro.response.status = 404;
    return Astro.rewrite('/404');
  }
  throw error;
}
---

Testing your own components

Use Astro's experimental_AstroContainer (astro/container) — see packages/astro/CONTRIBUTING.md. Note: LocalessComponent.astro/LocalessDocument.astro themselves aren't unit-testable this way (they depend on virtual:* modules only resolvable inside a real Astro build) — verify changes to them manually against playgrounds/astro/playgrounds/astro-static.

Component naming strategies

A Localess schema can be named anything; every framework has its own file-naming convention. The componentNaming option reconciles them by normalizing both the registry key and the incoming data._schema before they are compared.

Strategy HeroBanner / hero-banner / hero_banner ->
exact (default) unchanged — matches only an identical spelling
camelCase heroBanner
PascalCase HeroBanner
kebab-case hero-banner
snake_case hero_banner
lowercase herobanner — separators dropped entirely

Every strategy except exact is case- and separator-insensitive, so they differ only in the shape of the key they produce, not in what they match.

Name your component files after your schemas and the default exact works with no configuration. Reach for another strategy when the two conventions genuinely differ — e.g. schemas named hero-banner and files named HeroBanner.

Under any strategy other than exact, two files that normalize to the same key (HeroBanner and hero-banner in one directory) collide; the SDK logs a warning naming both and keeps the first.

See ADR 012.

Changed in v4: the default was previously camelCase — Astro always matched case- and separator-insensitively. It is now exact. If your .astro filenames differ in case from your schema names, set componentNaming: 'camelCase' to restore the previous behaviour. toCamelCase() is still exported but deprecated.

Astro serializes its integration options into a virtual module with JSON.stringify, so componentNaming accepts the strategy names only, not a custom function.