Skip to content
Twilight React Playground
ثيم رائدaren

mergeDocumentDescriptors

functionAdvancedserverbrowserlive demo

Merges several { html, body } attribute descriptors into React props for the html and body elements: classes combined, other attributes last-wins.

import { mergeDocumentDescriptors, DocumentElementDescriptor, DocumentElementAttrs } from '@salla.sa/twilight-theme-engine/utils';

In plain words

Classes and attributes on <html> and <body> (lang, dir, class="page-cart") often come from several places: the layout, the page, the theme. A descriptor is a small object saying what one of them wants: { html: { lang: 'ar' }, body: { class: 'page-cart' } }.

mergeDocumentDescriptors(a, b, …) combines any number of them into { htmlAttrs, bodyAttrs }, objects you spread onto the elements, as in <body {...bodyAttrs}>. It only calculates; it changes nothing on the page.

Signature

function mergeDocumentDescriptors(...descriptors: DocumentElementDescriptor[]): {
  bodyAttrs: Record<string, string>;   // React names: className
  htmlAttrs: Record<string, string>;
}

interface DocumentElementDescriptor {
  body?: DocumentElementAttrs;
  html?: DocumentElementAttrs;
}

interface DocumentElementAttrs {
  class?: string | string[];                       // combined across descriptors
  [attr: string]: string | string[] | undefined;   // the last non-empty string wins
}

Try it live

Two descriptors, a layout and a page, merged into props for the html and body elements.Try this: repeat a class in both: it appears once. Empty the page lang: an empty string is skipped, so the layout value stays.
Storefront canvas · en · LTR
htmlAttrs = {
  "lang": "en",
  "dir": "rtl"
}

bodyAttrs = {
  "data-page": "cart",
  "className": "font-sans antialiased page-cart"
}
Controls
What a theme writes
import { mergeDocumentDescriptors } from '@salla.sa/twilight-theme-engine/utils';

const { bodyAttrs, htmlAttrs } = mergeDocumentDescriptors(
  { html: { lang: 'ar', dir: 'rtl' }, body: { class: 'font-sans antialiased' } },
  { html: { lang: 'en' }, body: { class: 'page-cart antialiased', 'data-page': 'cart' } }
);

export function Shell({ children }: { children: React.ReactNode }) {
  return (
    <html {...htmlAttrs}>
      <body {...bodyAttrs}>{children}</body>
    </html>
  );
}

Example

app/routes/__root.tsx (excerpt)
import { HeadContent, Outlet, Scripts } from '@tanstack/react-router';
import { TwilightProvider } from '@salla.sa/twilight-theme-engine';
import { getTwilightContext } from '@salla.sa/twilight-theme-engine/tanstack';
import { mergeDocumentDescriptors } from '@salla.sa/twilight-theme-engine/utils';

function RootComponent() {
  const ctx = getTwilightContext();
  const { htmlAttrs, bodyAttrs } = mergeDocumentDescriptors(
    { html: { lang: ctx.locale, dir: ctx.dir }, body: { class: 'font-sans antialiased' } },
    { body: { class: 'theme-lookbook', 'data-theme': 'lookbook' } }
  );

  return (
    <html {...htmlAttrs} suppressHydrationWarning>
      <head>
        <HeadContent />
      </head>
      <body {...bodyAttrs} suppressHydrationWarning>
        <TwilightProvider>
          <Outlet />
        </TwilightProvider>
        <Scripts />
      </body>
    </html>
  );
}

How it behaves

  • class values from every descriptor are split on whitespace and combined without duplicates, in first-seen order. For every other attribute the last descriptor with a non-empty string wins.

  • The output uses React names: class becomes className, and nothing else is renamed.

  • It is toReactProps(mergeElementAttrs(…)) for each element. mergeAttrs from @salla.sa/twilight-theme-engine/tanstack and from /nextjs is this same function.

  • Pure: it works in a server render and never touches the DOM. For attributes that follow a component, use useDocumentClass(), which merges through DocumentClassProvider and updates the real elements.

Gotchas

  • An empty string never clears an earlier value: { html: { dir: '' } } after { html: { dir: 'rtl' } } leaves rtl. Leave the attribute out of the earlier descriptor instead.

  • Arrays count only for class. The type accepts string[] for any attribute, but on other attributes an array is dropped without a warning.

  • TwilightProvider writes lang, dir and theme classes to the same <html> and <body> after hydration, through DocumentClassProvider. Its classes are added to yours, but for lang and dir its value replaces the one you rendered.

  • docs/20-document-class.md lists DocumentElementDescriptor and DocumentElementAttrs under @salla.sa/twilight-theme-engine. The package root exports neither; import them from @salla.sa/twilight-theme-engine/utils.

Related

Source and docs