npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@symbo.ls/helmet

v3.14.675

Published

SEO metadata plugin for Symbols apps. Shared between runtime (browser) and SSR (brender).

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)).

  1. Each layer resolves its metadata via resolveMetadata(): function values run, {{ }} templates resolve, and files-map keys become file URLs.
  2. 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.
  3. The live head is the merge of all layers. Each key takes the value of the highest layer that sets it (null / undefined sets nothing).
  4. 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.html head 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.
  5. title and description cascade into og:title, og:description, twitter:title and twitter:description as the server head does (see SSR below). A page that sets title gets it in og:title, unless the page (or a layer above it) sets og:title itself.

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 polyglot key whose language is still loading — translate marks it through @symbo.ls/utils evaluateText) 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 metadata of its component.
  • An object entry gets its own metadata (or helmet) over the component's: the entry's keys win, and the component's other keys stay. A function as the whole metadata replaces the component's. A function per key resolves as usual.
  • The component is found as the page's extends chain 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:

  1. Finds the page: the key route names, else the key the render's router mounted for the address (state.route, a key with params), else the catch-all (/*) or a page flagged notFound
  2. Merges metadata from data.integrations.seo (lowest priority)
  3. Merges data.app.metadata (medium priority)
  4. Merges the route entry's head (highest priority): an object entry's metadata or helmet over the static metadata of the component it extends; for a string entry, the component's (see A route entry's head)
  5. Falls back to page.state.title / page.state.description if not set
  6. Cascades title / description into og:title / og:description (unless the page sets them) and twitter:title / twitter:description (unless set anywhere)
  7. Appends the route to a base og:url (or url) from data.integrations.seo or data.app.metadata. A page's own og:url (or url) is that page's address and stays as written.
  8. 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.