@symbo.ls/helmet
v3.14.675
Published
SEO metadata plugin for Symbols apps. Shared between runtime (browser) and SSR (brender).
Maintainers
Keywords
Readme
@symbo.ls/helmet
SEO metadata plugin for Symbols apps. Shared between runtime (browser) and SSR (brender).
Usage
On pages and app
Define metadata on your app or any page/component. It's registered as a global define, so it works on any element:
// app.js — app-level defaults
export default {
metadata: {
title: 'My App',
description: 'Built with Symbols',
'og:image': '/social.png'
}
}
// pages/about.js — page-level (overrides app-level)
export const about = {
metadata: {
title: 'About Us',
description: 'Learn more about us'
}
}Dynamic metadata
Both the metadata object and individual properties can be functions receiving (element, state):
// Whole object as function
export const product = {
metadata: (el, s) => ({
title: s.product.name,
description: s.product.description,
'og:image': s.product.image
})
}
// Individual properties as functions
export const profile = {
metadata: {
title: (el, s) => `${s.user.name} — My App`,
description: (el, s) => s.user.bio,
'og:image': '/default-avatar.png'
}
}To replace an element's metadata at runtime, pass a factory, or null to
remove it:
el.update({ metadata: (el, s) => ({ title: 'Checkout', robots: 'noindex' }) })
el.update({ metadata: null })update() does not assign an object to a lowercase prop, so
el.update({ metadata: { title: 'Checkout' } }) changes nothing.
Helmet component
For explicit use without the global define, extend the Helmet component:
import { Helmet } from '@symbo.ls/helmet'
export const MyPage = {
extends: Helmet,
metadata: { title: 'My Page' }
}How it works
Runtime (browser)
Every element with metadata adds a layer to the document head. The
helmet plugin (registered by smbls) opens the layer when the element is
created. A signal effect keeps the layer current, so a language switch
re-resolves it. The layer closes when the element is disposed (the router
swaps the page, a list row leaves, destroy(app)).
- Each layer resolves its metadata via
resolveMetadata(): function values run,{{ }}templates resolve, and files-map keys become file URLs. - Layer order: an element sits above its ancestors, so the app is the lowest layer. Of two unrelated elements, the one mounted later sits above.
- The live head is the merge of all layers. Each key takes the value of the
highest layer that sets it (
null/undefinedsets nothing). - When no layer sets a key any more (its element left, or its metadata
dropped the key), the key falls back:
- to the next layer that sets it, for example the app-level value;
- else to the value the served document had before helmet first wrote that
tag (the static
index.htmlhead or the server-rendered head). Helmet restores that value only while the document still shows the path it was served for, and only if no layer wrote that same value. A server-rendered head describes the page it was rendered for, so on any other path its page values are stale; - else the tag is removed.
titleanddescriptioncascade intoog:title,og:description,twitter:titleandtwitter:descriptionas the server head does (see SSR below). A page that setstitlegets it inog:title, unless the page (or a layer above it) setsog:titleitself.
Helmet never touches a tag that no layer set.
Put route-independent defaults in the app's metadata. They then hold on
every route. A page only sets what it changes.
Labels still loading, and a head the build wrote
The live head keeps what the document shows until a real value is there:
- A value that is a label's stand-in (a
polyglotkey whose language is still loading —translatemarks it through@symbo.ls/utilsevaluateText) is never written. The tag keeps the served value, or the last real one, and the label is written when it lands. - On a page the build wrote (
<meta name="brender">/<meta name="brender-stamp">in the served head), on the path it was served for, the app root's metadata does not replace a tag the document carries. The page's own metadata is written as soon as it is ready. A navigation, or a change of the app's values (a language switch), ends this. - A head the host wrote is kept the same way when the host marks it (see A head the host wrote below).
So the app's defaults name labels, and the static text before the labels load is written once, in index.html:
// app.js
metadata: {
title: '{{ ui.meta.site_name | polyglot }}',
description: '{{ ui.meta.description | polyglot }}',
'og:type': 'website'
}A head the host wrote
A host can write each page's own head into the page it serves, for example an edge Worker that puts the route's title and description into an app shell. To keep that head, the host adds one tag to it:
<meta name="smbls-head" content="served">Helmet then keeps the head as it keeps a head the build wrote (above). On the path it was served for, the app root's metadata does not replace a tag the document carries. The page's own metadata is written, and a value equal to the served one writes nothing. A navigation ends this.
The marker is opt-in. Without it, the app's defaults fill every tag that the page does not set.
The first head write
An app that routes writes its head once, when its first page is in: the app's metadata and the first route's page metadata, merged in one pass. The layers that open before that (the app root's, a shell's, the page's) only take their values. Helmet renders the head when the first route has changed (the routeChanged hook), or when the first navigation settles without one (an onRenderRouter of the app's own that routes nothing, a navigation that fails).
So at boot the served title changes at most once, to the first page's title. The app's title alone is never written first.
An app without routes, or with router: false, writes its head at once. A document without an address (a server render) does not wait.
A noindex the host forced
A non-production channel (development, staging) serves this robots tag (generateMetaTags in @symbo.ls/utils):
<meta name="robots" content="noindex" data-smbls-host="forced">The mark says that the tag is the host's, not the app's. Into a marked tag, helmet writes only a robots value that keeps the page out of the index (it contains noindex or none, for example noindex, nofollow). It does not write an indexable value (index, follow), and it never removes the tag. applyMetadata keeps the same rule.
A production head carries no mark. There the project's robots applies.
A route entry's head
A route entry names the component of its page. It can also carry a head of its own:
// pages/index.js
export default {
'/ask': 'SiteAiPage',
'/blueprint': { extends: 'SiteAiPage', metadata: { title: '{{ ai.blueprint.title | polyglot }}', robots: 'noindex' } },
'/make': { extends: 'SiteAiPage', metadata: { title: '{{ ai.make.title | polyglot }}' } }
}- A string entry gets the static
metadataof its component. - An object entry gets its own
metadata(orhelmet) over the component's: the entry's keys win, and the component's other keys stay. A function as the wholemetadatareplaces the component's. A function per key resolves as usual. - The component is found as the page's
extendschain is: the project's components, then the linked libraries. The walk reads at most 8 components.
The served head (getPageMetadata in @symbo.ls/utils), the SSR head (extractMetadata) and the browser use this one rule (routeEntryMetadata in @symbo.ls/utils).
Two entries that name the same component and carry nothing but a head (extends with metadata / helmet, or a string) are the same page for the router. It keeps the page element across them (no new mount) and gives the new entry's head to helmet (the routeHead hook). The head then shows what a fresh visit to that address shows. An entry with any other key is a page of its own.
A route with params: the head of each address
One key with params (/compare/:tool) serves every address it matches. Its head's templates read the values the router writes into the root state for the visited address: params (each :name segment, URI-decoded), route (the matched key) and routePath (/ and the address's first segment). A page with a state of its own reads them too: a key a child state lacks is read from the root.
// pages/index.js
export default {
'/compare/:tool': {
extends: 'SiteAiPage',
metadata: {
title: 'Symbols vs {{ params.tool }}',
canonical: 'https://symbols.app/compare/{{ params.tool }}'
}
},
'/apps/:owner/:key': {
extends: 'SiteAiPage',
metadata: { canonical: 'https://symbols.app/apps/{{ params.owner }}/{{ params.key }}' }
}
}/compare/hubspot serves the title Symbols vs hubspot and that canonical in each head: the served head (getPageMetadata, from the request's address), the SSR head (extractMetadata, from the render's router) and the browser's (after each navigation). The value is the address's segment: a title of the slug. For a name of its own, give that address a key of its own ('/compare/hubspot': { extends: 'SiteAiPage', metadata: { title: 'Symbols vs HubSpot' } }) — an exact key wins over a key with params, and it is the same page for the router. In the served head, a template sees the page's prefetched state first, then these values, then state.js; a {{ x | fn }} filter other than polyglot prints the value as it is there (the served head runs no project function).
Array keys
| Key | Tags | When the layer that set it leaves |
|-----|------|-----------------------------------|
| jsonLd | <script type="application/ld+json" data-smbls-jsonld> | removed, or the next layer's list |
| styles | <style data-smbls-style> | removed, or the next layer's list |
| alternate | <link rel="alternate" data-smbls-alternate> | removed, or the next layer's list |
| favicons | <link rel="icon" data-smbls-favicon> | removed, or the next layer's list |
| icon / favicon | <link rel="icon" data-smbls-single-icon> | removed, or the next layer's icon |
| scripts | <script src data-smbls-script> | stays (load-once) |
The highest layer's list replaces the lists below it, as the server merge
does. scripts is load-once: a script runs when it is inserted, and removing
it undoes nothing. Helmet adds every script that a mounted layer declares,
once, and never removes it.
SSR (brender)
Brender calls extractMetadata(data, route) which:
- Finds the page: the key
routenames, else the key the render's router mounted for the address (state.route, a key with params), else the catch-all (/*) or a page flaggednotFound - Merges metadata from
data.integrations.seo(lowest priority) - Merges
data.app.metadata(medium priority) - Merges the route entry's head (highest priority): an object entry's
metadataorhelmetover the staticmetadataof the component it extends; for a string entry, the component's (see A route entry's head) - Falls back to
page.state.title/page.state.descriptionif not set - Cascades
title/descriptionintoog:title/og:description(unless the page sets them) andtwitter:title/twitter:description(unless set anywhere) - Appends the route to a base
og:url(orurl) fromdata.integrations.seoordata.app.metadata. A page's ownog:url(orurl) is that page's address and stays as written. - Resolves bare filenames against
data.files
Then generateHeadHtml(metadata) produces the <head> HTML string.
The alternate, favicons and icon tags carry the same data-smbls-*
keys that the runtime uses (see Array keys). A client that boots on a
server-rendered page takes these tags over: it adds no second copy, and a
page's tags leave with the page. The static head (generateMetaTags in
@symbo.ls/utils, used by the mermaid server and the runner) applies the same
keys and the same og:url rule.
API
resolveMetadata(metadata, element, state)
Resolves metadata values. If metadata is a function, executes it with (element, state). Then executes any function values in the resulting object.
applyMetadata(metadata, doc)
Applies a resolved metadata object to the live DOM. Creates or updates <title>, <meta>, and <link> tags.
This is the stateless writer: it writes the keys of one object and removes no other key. The runtime does not use it. The runtime renders the merge of the layers (see Runtime above).
extractMetadata(data, route, element?, state?)
Extracts and merges metadata from project data for a given route. When element and state are provided, function values are resolved.
generateHeadHtml(metadata)
Generates an HTML <head> string from a flat metadata object. Used by brender for SSR.
Helmet
DOMQL component with a define.metadata handler. Use when you need the define on a specific component rather than globally.
Supported keys
Standard keys with shorthand support:
| Key | Tag |
|-----|-----|
| title | <title> |
| description | <meta name="description"> |
| keywords | <meta name="keywords"> |
| robots | <meta name="robots"> |
| author | <meta name="author"> |
| canonical | <link rel="canonical"> |
| image | <meta property="og:image"> |
| url | <meta property="og:url"> |
| siteName | <meta property="og:site_name"> |
| type | <meta property="og:type"> |
| locale | <meta property="og:locale"> |
| theme-color | <meta name="theme-color"> |
Any og:* or twitter:* key is also supported directly:
metadata: {
'og:title': 'Social Title',
'og:description': 'Social description',
'twitter:card': 'summary_large_image',
'twitter:site': '@myapp'
}The generateHeadHtml() function also handles article:*, product:*, fb:*, DC:*, http-equiv:*, itemprop:*, alternate, and favicon/favicons keys.
domql plugin
Helmet works out of the box via the define system (context.define or element.define). It can also be used as a domql plugin to hook into the element lifecycle:
import { helmetPlugin } from '@symbo.ls/helmet'
context.plugins = [helmetPlugin]This registers helmet as a lifecycle plugin alongside other plugins like funcqlPlugin or shorthandPlugin:
context.plugins = [funcqlPlugin, helmetPlugin]Plugin interface
{
name: 'helmet',
create (element) { ... } // opens the element's head layer
update (element) { ... } // re-resolves it after el.update()
routeChanged (element) { ... } // the first route is in: renders the first head (The first head write)
routeHead (element, head) { ... } // the router kept the page for another entry: shows that entry's head (A route entry's head)
}The layer closes with the element's effects, when the element is disposed.
