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

@laioutr/app-essentials-seo

v1.6.0

Published

SEO essentials for Laioutr frontends — sitemap.xml, robots.txt, llms.txt and Markdown twins

Readme

@laioutr/app-essentials-seo

Laioutr npm version npm downloads License Nuxt

SEO essentials for Laioutr frontends: a per-host sitemap_index.xml, its child sitemaps, robots.txt, llms.txt, Markdown twins of every page, and the Open Graph tags a page's <head> needs to share well.

Every response this module serves is derived from the requesting host. A Laioutr project can span several markets and domains behind one deployment; this module resolves the correct, host-specific sitemap and robots.txt for whichever host the request actually came in on, so there is nothing to build or configure per market.

What it serves

Three routes:

  • /sitemap_index.xml — the per-host sitemap index. Lists only the child sitemaps whose locale the requesting host actually serves.
  • /__sitemap__/<name>.xml — the child sitemaps themselves.
  • /robots.txt — per-host, with its Sitemap: line resolved against the requesting host.

Plus, for AI agents and other Markdown-preferring clients:

  • Markdown twins — <path>.md for every page (/index.md for the home page), converted from the rendered HTML with mdream. Header and footer sections and anything marked data-markdown-ignore are left out; schema.org JSON-LD is appended under ## Structured Data. Clients that prefer Markdown (Accept: text/markdown, known AI agents) are redirected there with a 307. A noindex page's twin carries the same directive as X-Robots-Tag, and every twin on a deployment or market that isn't indexable (see below) carries X-Robots-Tag: noindex, nofollow outright, since frontend-core renders no robots meta there for this module to copy.
  • /llms.txt — per host: site name and description, authored notes and sections, the configured pages, and one line per dynamic page type (URL pattern, count once its sitemap is complete). Deliberately curated rather than a list of every URL — the sitemap already is that.
  • Discovery — <link rel="alternate" type="text/markdown"> and rel="describedby" → /llms.txt in the page head and the Link header.

Plus Open Graph tags on every page — see below.

Open Graph

frontend-core renders <title>, meta[description], meta[robots], rel=canonical, hreflang, and og:locale / og:locale:alternate on its own. This module adds the rest of the Open Graph set by filtering that head through frontend-core:page-head:resolve:

| Tag | Derived from | | --- | --- | | og:title | The page's resolved SEO title. Omitted when the page has none and frontend-core fell back to the bare page type. | | og:description | The page's resolved SEO description. | | og:type | The page type, via openGraph.pageTypes — which already maps blog posts, product detail pages and location detail pages. Every other page type gets openGraph.defaultType. | | og:url | The canonical link, so the two can never disagree. Omitted when no market domain resolved (Studio preview). | | og:site_name | siteName, or the requesting host's market name. |

Both title and description are read after frontend-core has substituted any {{queries.…}} placeholder and applied the locale chain, so a dynamic page shares the entity's own values.

Existing values are never overwritten: a tag another app or the project itself already set survives. og:image and Twitter cards are not emitted yet.

Structured data

Every page gets a schema.org @graph with a WebSite and a WebPage node, via nuxt-schema-org. Both are named and addressed per host: each host uses its market's name (or siteName) and its own URL.

Configure the organization behind the site under structuredData.organization:

{
  "structuredData": {
    "organization": {
      "legalName": "Example GmbH",
      "logo": "/logo.png",
      "email": "[email protected]",
      "telephone": "+49 30 0000000",
      "address": {
        "streetAddress": "Musterstraße 1",
        "postalCode": "10115",
        "addressLocality": "Berlin",
        "addressCountry": "DE"
      },
      "sameAs": ["https://social.example/example"],
      "vatID": "DE000000000"
    }
  }
}

| Field | Notes | | --- | --- | | type | Organization (default) or LocalBusiness. | | name | Leave it out to use each host's own site name. | | logo | A path or an absolute URL. Google asks for at least 112×112 pixels. | | everything else | Passed through as schema.org properties of the same name. |

Fields that differ per shop — the e-mail, the logo — go under markets.

Without organization, the graph has no Organization node. structuredData.enabled: false removes the graph entirely.

Sections that emit their own structured data — the breadcrumbs, and an accordion set to FAQ in @laioutr-app/ui — add their nodes to the same graph (with @laioutr-core/frontend-core 0.62.0 or later).

Only the server-rendered HTML carries the graph; it is not updated on client-side navigation.

One unhead version. nuxt-schema-org brings @unhead/schema-org 2.1, which needs the project's @unhead/vue and unhead on 2.1 too. A project still locked to unhead 2.0 ends up with two unhead copies, and every server-rendered page then fails. Align them once after installing:

pnpm update --depth Infinity @unhead/vue @unhead/schema-org unhead

pnpm-lock.yaml should then list a single unhead@ version. When nuxt-schema-org's unhead is newer than the project's, this module stops a production build with that command; an older one only warns.

Child sitemap naming

You will see these names in the index and in logs, so it helps to know how they're built:

  • A configured page (a page declared directly in the project, with no route params) becomes pages-<locale>.xml.
  • A page-index-backed page type becomes its token with every / flattened to -, followed by -<locale>. The token ecommerce/product-detail-page in de becomes ecommerce-product-detail-page-de.xml.

When a child sitemap is large enough to be chunked, the upstream @nuxtjs/sitemap module appends a numeric suffix — ecommerce-product-detail-page-de-0.xml, ...-de-1.xml, and so on. This module resolves the suffixed name back to its source transparently; there is nothing to configure for it.

Configuration

Configure this module under its own package name, @laioutr/app-essentials-seo — that's also the app config key, since Cockpit only permits app configuration under the package name.

Top level

| Field | Type | Default | Notes | | --- | --- | --- | --- | | siteName | string | unset | Site name used for every market. When unset, each market falls back to its own name. markets.<id>.siteName overrides it for one market. | | markets | Record<marketId, …> | {} | Per-market settings — see below. | | indexable | 'auto' \| 'always' \| 'never' | 'auto' | 'auto' leaves indexability unset, so it falls back to environment === 'production'. 'always'/'never' force it regardless of environment. | | environment | 'production' \| 'staging' \| 'preview' \| 'development' | unset | Falls back, in order, to the NUXT_SITE_ENV, NUXT_PUBLIC_SITE_ENV, then VERCEL_ENV environment variables, and finally 'production' if none are set. |

markets

Settings that differ between the shops of one project, keyed by market id. The id stays the same when a market is renamed. Each entry overrides the project-wide value on that market's hosts:

{
  "siteName": "Example",
  "structuredData": {
    "organization": { "legalName": "Example GmbH", "telephone": "+49 30 0000000" }
  },
  "markets": {
    "<market id>": {
      "siteName": "Example NL",
      "structuredData": {
        "organization": { "email": "[email protected]", "logo": "/logos/example-nl.png" }
      }
    }
  }
}

| Field | Notes | | --- | --- | | siteName | The site name on this market's hosts: og:site_name and the schema.org WebSite and Organization names. | | structuredData.organization | Only the fields that differ. They replace the project organization's fields; address and sameAs are replaced whole. A market can also have an organization when the project has none. |

A market id that matches no market of the project is ignored, with a build warning.

sitemap

| Field | Type | Default | Notes | | --- | --- | --- | --- | | enabled | boolean | true | | | excludePageTypes | string[] | [] | Page type tokens to drop from the sitemap entirely, and the only way to do so. Applies to both source kinds: a configured page of an excluded type never becomes a URL, and a page-index-backed type gets no child sitemap at all — it is neither enumerated nor listed in the index. | | pageTypes | PageTypeSeo[] | [] | Per-page-type overrides. See below. | | defaultChangefreq | 'always' \| 'hourly' \| 'daily' \| 'weekly' \| 'monthly' \| 'yearly' \| 'never' | unset | Fallback changefreq for a URL that doesn't set its own. | | defaultPriority | number (0–1) | unset | Fallback priority for a URL that doesn't set its own. | | includeImages | boolean | true | Emits an <image:image> entry when a page-index entry carries a preview image. Only applies to page-index-backed sources — configured pages never carry an image. | | entriesPerRequest | integer (≥ 1) | 10000 | How many entries a single request may pull for a snapshot rebuild pass. See "Operational notes" below for what happens at the boundary. |

sitemap.pageTypes[] — per-page-type SEO metadata, one entry per page type you want to override. It only tunes how a page type's URLs are described; to keep a page type out of the sitemap altogether, list it in excludePageTypes.

| Field | Type | Required | Notes | | --- | --- | --- | --- | | pageType | string | yes | The page type token, e.g. ecommerce/product-detail-page. | | priority | number (0–1) | no | | | changefreq | same enum as defaultChangefreq | no | |

robots

| Field | Type | Default | Notes | | --- | --- | --- | --- | | enabled | boolean | true | | | blockAiBots | boolean | false | Passed through to @nuxtjs/robots. | | blockNonSeoBots | boolean | false | Passed through to @nuxtjs/robots. | | extraDisallow | string[] | [] | Appended to the wildcard (*) group's Disallow list, alongside this module's own internal /api/ and /_laioutr/ entries. | | customGroups | RobotsGroup[] | [] | Additional User-agent groups. See below. | | localizeRules | boolean | true | Repeat each rule under the language prefixes the requested host serves. See below. | | contentUsage | string[] or preferences object | ['search=y, ai-output=y'] | Content-Usage: for the wildcard (*) group. See "Content Signals default" below. | | contentSignal | string[] or preferences object | ['search=yes, ai-input=yes'] | Content-Signal: for the wildcard (*) group. Same defaults, other vocabulary. |

robots.customGroups[]:

| Field | Type | Default | | --- | --- | --- | | userAgent | string[] | ['*'] | | allow | string[] | [] | | disallow | string[] | [] | | contentUsage | string[] or preferences object | [] | | contentSignal | string[] or preferences object | [] |

AI-preference lines. allow/disallow say whether a crawler may fetch a URL. contentUsage and contentSignal say what it may then do with what it fetched. They are competing IETF drafts covering the same ground — aipref-vocab and aipref-contentsignals — so a site that wants to be understood by both sets them both.

| | contentUsage → Content-Usage: | contentSignal → Content-Signal: | | --- | --- | --- | | Categories | bots, train-ai, ai-output, search | search, ai-input, ai-train | | Values | y, n | yes, no |

Either field takes a preferences object for a blanket statement, or a list of raw rule strings when you need to scope one to a path — the form the object cannot express:

robots: {
  customGroups: [
    // Content-Usage: train-ai=n, ai-output=n
    { userAgent: ['*'], contentUsage: { 'train-ai': 'n', 'ai-output': 'n' } },
    // Content-Signal: search=yes
    // Content-Signal: /members ai-train=no
    { userAgent: ['*'], contentSignal: ['search=yes', '/members ai-train=no'] },
  ],
}

Rules are validated at build time, so a mistyped category or a value from the other vocabulary fails the build instead of shipping as a line no crawler acts on.

Content Signals default. The wildcard (*) group states a preference out of the box, so a project that sets nothing still answers both drafts:

  • Content-Usage: search=y, ai-output=y
  • Content-Signal: search=yes, ai-input=yes

train-ai / ai-train is deliberately left unset: allowing or reserving training use is the site owner's legal decision (in the EU, a no acts as the Art. 4 DSM text-and-data-mining opt-out, a yes waives it), and "no preference stated" is the honest default this module can give on their behalf. Set robots.contentUsage / robots.contentSignal to override the wildcard group's statement, or to [] to clear it. A value already set on the * group through customGroups or raw robots config wins over this default.

robots.localizeRules. A Disallow matches on the URL path, so a rule written once binds only where it was written: on a host serving German at the root and English under /en, Disallow: /login never reaches /en/login. With localizeRules on — the default — each rule is repeated under every language prefix the requested host serves:

User-agent: *
Disallow: /login
Disallow: /en/login

Each host is answered with its own prefixes and no other host's, so a second market on a second domain does not collect rules for paths it never serves. The rewrite is additive: the rule as authored is always kept, and four kinds of rule are left alone —

  • a rule already scoped to a language (/en/login), on the assumption that was deliberate;
  • a wildcard rule (*/login), which spans every prefix already under RFC 9309;
  • the module's own /api/ and /_laioutr/, which are app-root routes served under no prefix;
  • everything in a prerendered robots.txt, which has no request host to resolve prefixes from.

It repeats prefixes, not translations. Laioutr page paths are themselves localizable, so if the German route is authored as /anmelden and the English one as /login, this produces /anmelden and /en/anmelden — the English spelling still needs its own rule. Set localizeRules: false to emit rules exactly as written.

openGraph

| Field | Type | Default | Notes | | --- | --- | --- | --- | | enabled | boolean | true | | | defaultType | string | 'website' | og:type for every page type without a pageTypes entry. | | pageTypes | Record<string, string> | see below | og:type keyed by page type token. |

openGraph.pageTypes — maps a page type token to its og:type, and ships with the canonical page types for which the generic website would be wrong:

{
  'blog/post-single': 'article',
  'ecommerce/product-detail-page': 'product',
  'location/detail': 'place',
}

What you configure is merged over that map, one page type at a time: an entry for blog/post-single changes that page type alone and the other two defaults survive. Every page type without an entry — the core/* set, both blog listings, product listing and search, location/finder, and anything another package registers — falls through to defaultType.

Both sides are free-form strings, since the page type vocabulary is open-ended and so is the og:type one. There is no marker for deleting a default: to move a page type back to the generic type, set it to 'website'.

aiReady

Configure Markdown twins, content negotiation and /llms.txt under aiReady. All of it only ever answers GET/HEAD requests — a POST to a page URL is left alone.

| Field | Type | Default | Notes | | --- | --- | --- | --- | | enabled | boolean | true | | | contentNegotiation | boolean | true | Redirects a request that prefers Markdown — Accept: text/markdown, or a known AI agent, as decided by @mdream/js/negotiate — from an HTML route to its .md twin with a 307. A route opts out by carrying ISR, or a cache route rule whose varies doesn't include Accept: a cached response can't vary by what asked for it, so it's served without negotiating. Negotiated responses send Vary: Accept only — not User-Agent, which would split caches per browser build — so an AI agent that gets a cached HTML copy finds the twin through the Link header instead of a redirect. | | describedby | boolean | true | Advertises /llms.txt as rel="describedby", in the page head and in the Link header on both the HTML page and its twin. | | mdreamOptions | Partial<MdreamOptions> | see below | mdream conversion options, merged over this module's own defaults with defu (arrays concatenate) — add a selector without restating ours. | | markdownCacheHeaders | { maxAge: number, swr: boolean } \| false | { maxAge: 3600, swr: true } | Cache-Control for a .md twin. Only a 200 twin is cached this way — a page that 404s gets no Cache-Control from here, so a briefly-missing page doesn't stay 404 on the CDN for maxAge. | | llmsTxtCacheSeconds | number | 600 | How long /llms.txt is cached, keyed by host so one market's file never serves another's. | | llmsTxt.markdownLinks | boolean | true | Link ## Pages entries to their .md twin rather than the HTML page. Upstream's own default is false. | | llmsTxt.notes | string \| string[] | [] | Freeform preamble, rendered under Notes: ahead of the generated sections. | | llmsTxt.sections | LlmsTxtSection[] | [] | Authored sections — see below. | | llmsTxt.pageTypes | Record<string, { title?, description? } \| false> | {} | Title/description per dynamic page type in ## Page Types, keyed by the page type token; false leaves that type out entirely. This module's own addition — not part of nuxt-ai-ready's aiReady. |

Default mdreamOptions:

{
  minimal: true,
  clean: true,
  filter: {
    exclude: [
      '[data-lfc-location="header"]',
      '[data-lfc-location="footer"]',
      '[data-markdown-ignore]',
    ],
  },
  isolateMain: false,
}

frontend-core renders no <main>; every section root carries data-lfc-location instead, which the default filter uses to leave header and footer sections out of the Markdown. isolateMain: false turns off mdream's own guess at a "main content" region — left at its minimal preset's default, it drops any content before a page's first heading, hero and intro copy included, which this module has no more insight into than mdream does.

llmsTxt.sections[]:

| Field | Type | Default | Notes | | --- | --- | --- | --- | | title | string | — | | | description | string \| string[] | unset | | | optional | boolean | false | Renders under ## Optional instead of getting its own heading. | | links | { title: string, href: string, description?: string }[] | [] | |

Option names mirror nuxt-ai-ready's aiReady, so this block can be handed to that module unchanged once frontends run Nuxt 4. llmsTxt.pageTypes is this module's own addition.

On a deployment or market that isn't indexable — a non-production environment, or a market that hasn't launched — /llms.txt keeps its header and authored sections but leaves ## Pages and ## Page Types out, the same rule the sitemap follows.

Leaving content out of the Markdown. Put data-markdown-ignore on any element:

<div class="stock-badge" data-markdown-ignore>Only 3 left!</div>

A section or block definition can leave itself out entirely with rendering: { markdown: false } (needs @laioutr-core/frontend-core with Markdown opt-out support).

Example

export default defineNuxtConfig({
  modules: ['@laioutr/app-essentials-seo'],
  '@laioutr/app-essentials-seo': {
    indexable: 'auto',
    environment: 'production',
    sitemap: {
      excludePageTypes: ['core/checkout'],
      pageTypes: [{ pageType: 'ecommerce/product-detail-page', changefreq: 'daily', priority: 0.8 }],
      includeImages: true,
    },
    robots: {
      blockAiBots: true,
      extraDisallow: ['/search'],
    },
    openGraph: {
      // Overrides one shipped default; the product and location ones are untouched.
      pageTypes: { 'blog/post-single': 'blog' },
    },
  },
});

The extension hook

This module fires a Nitro server hook, essentials-seo:sitemap-source:built, whenever it builds a sitemap source — with the entries that built it. Another app in the project can hook into it to filter or enrich the URLs before they're stored and served — for example, to drop out-of-stock products from the crawlable set.

When it fires depends on how the source is built:

  • Configured pages are finite, so that source is rebuilt in full on every request and the hook fires every time.
  • A page-index-backed source is accumulated over one or more rebuild passes (see "Operational notes"), and the hook fires once per pass, carrying only the URLs that pass added. Whatever it leaves behind is what lands in the snapshot, so a filter applied here survives into every later request served from that snapshot.
  • A request answered from the snapshot cache does not fire it — there is nothing new to filter, and re-offering an already-filtered snapshot would mean applying the filter twice.

The payload carries:

  • event — the current H3Event.
  • token — the page type token for this source, e.g. 'ecommerce/product-detail-page'. null on the configured-pages source (the source that lists the project's finite, non-parameterized pages).
  • locale, market, domain — the locale and resolved market/domain this source is being built for.
  • urls — the URLs this build produced. Mutate this array in place — reassigning it to a new array is not observed by the caller. It holds one whole pass, not one URL — expect to filter or map the array in one call, not to be invoked per entry — and never the URLs earlier passes already contributed, which were offered in the pass that built them.
  • entries — the raw entries urls was built from, given as context for filtering decisions: this pass's enumerated page-index entries, or every configured page on the configured-pages source. Not positionally aligned with urls: entries dropped along the way (flagged noindex, missing required params, or otherwise unfillable) never reach urls, so entries is a superset. Do not zip the two arrays together by index — match on entry identity instead.

A worked example. entries is what makes this possible: a page-index entry carries the identity (subject, params) the decision needs, while a URL only carries its loc.

// server/plugins/filterOutOfStockProducts.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('essentials-seo:sitemap-source:built', async (ctx) => {
    // Only touch the product-detail-page source; leave every other source untouched.
    if (ctx.token !== 'ecommerce/product-detail-page') return;

    // Ask stock about exactly the products this pass built, not the whole catalogue.
    const entries = ctx.entries as PageIndexEntry[];
    const outOfStock = await getOutOfStockIds(ctx.market.id, entries.map((entry) => entry.subject?.id));
    const droppedSlugs = new Set(entries.filter((entry) => outOfStock.has(entry.subject?.id)).map((entry) => entry.params.slug));

    // The slug is the one part of an entry that reaches the URL, so it is what links a URL back to
    // the entry it came from. A trailing slash is stripped first, so both settings match.
    const slugOf = (loc: string) => loc.replace(/\/$/, '').split('/').pop();

    // `urls` must be mutated in place.
    const kept = ctx.urls.filter((url) => !droppedSlugs.has(slugOf(url.loc)));
    ctx.urls.length = 0;
    ctx.urls.push(...kept);
  });
});

Upstream hooks

To affect the underlying @nuxtjs/sitemap, @nuxtjs/robots or nuxt-site-config modules directly, rather than this module's own sources, reach for their hooks instead:

  • sitemap:sources (@nuxtjs/sitemap) — called once per sitemap while its sources are being assembled; push additional { context, urls } entries or inspect/replace ctx.sources.
  • sitemap:input (@nuxtjs/sitemap) — called with every source's URLs flattened into one list, before they're normalized into sitemap entries.
  • sitemap:resolved (@nuxtjs/sitemap) — called with the final, per-sitemap entries right before they're serialized to XML.
  • sitemap:index-resolved (@nuxtjs/sitemap) — called with the resolved list of child sitemaps right before the index is serialized. This module uses it to drop child sitemaps whose locale the requesting host doesn't serve.
  • robots:config (@nuxtjs/robots) — called with the resolved robots config before robots.txt is generated. This module uses it to add its sitemap line and internal disallow paths without clobbering a project's own rules.
  • robots:robots-txt (@nuxtjs/robots) — called with the fully rendered robots.txt context, for last-mile text edits.
  • site-config:init (nuxt-site-config) — called once per request as the per-host site config (name, url, indexable, ...) is resolved; the place to override site config in ways this module's own options don't expose.

This module also fires the three hooks nuxt-ai-ready fires around Markdown conversion, with the same names and payloads, so a listener written against them keeps working unchanged once a Nuxt 4 migration replaces this module's implementation with the upstream one:

  • ai-ready:markdown:source — called before a .md twin is rendered; set ctx.source to supply the page's Markdown directly and skip the render.
  • ai-ready:mdreamConfig — called with the resolved mdream options right before conversion; mutate them in place.
  • ai-ready:page:markdown — called with a page's converted Markdown; reassign ctx.markdown to adjust it.
// server/plugins/markdown.ts
export default defineNitroPlugin((nitro) => {
  nitro.hooks.hook('ai-ready:page:markdown', (ctx) => {
    ctx.markdown += `\n\nSource: ${ctx.route}`;
  });
});

Operational notes

  • Leave site.url unset on a multi-market project. If it's set — whether directly in nuxt.config.ts or via this module's own config — every market emits URLs on that single origin instead of each request deriving its own host. This module warns at build time when site.url is set alongside more than one configured host, but it does not unset it for you.
  • indexable: 'always' outranks environment, so it makes a non-production deployment crawlable. That is what the option is for, and it is left alone — but a staging or preview deployment in the index competes with production for the same content, so this module warns at build time when the two are combined, naming the resolved environment. Reach for 'auto' unless you specifically want the non-production URLs crawled.
  • A page type whose page-index registration ignores startCursor never pulls more than entriesPerRequest entries — and its sitemap can land fewer URLs than that, since a noindex or unfillable entry never becomes one. Sitemaps for page-index-backed page types are built incrementally, resuming from where the previous pass left off. Resumption depends on the registration's list handler threading the cursor through — returning paginate(fn, startCursor) is what makes a page type fully enumerable across passes. When a handler ignores startCursor instead, this module falls back to a single bounded read of up to entriesPerRequest entries and logs a warning naming the page type, so raising entriesPerRequest is the only way to cover more of that page type until its handler is fixed.
  • The first request for a page-index-backed page type on a given host is the expensive one. With nothing built yet to serve, it waits on a full pass — entriesPerRequest entries enumerated, mapped and stored — before it gets a response. Every request after that gets the stored snapshot immediately, while a pass advances it in the background. Coverage grows one pass per request, so a page type bigger than entriesPerRequest needs several requests, from crawlers or your own warm-up, before its sitemap is complete. entriesPerRequest is the knob to trade against that: lower it to shorten the first request, raise it to reach full coverage in fewer of them.

Quick Setup

Follow the Laioutr NPM Guide for connecting to npm.laioutr.cloud.

  • pnpm install
  • npx @laioutr/cli project fetch-rc --project <organization slug>/<project slug> --secret <project secret key> - This will load the laioutrrc.json file with the current remote project configuration.
  • pnpm dev:prepare

That's it! Sitemaps and robots.txt are now served for every host in your Laioutr Frontend.

You can find a thorough guide on getting started with Laioutr development in our developer guide.

Linting and Formatting

We use ESLint and Prettier to lint and format the code. This repository contains opinionated configurations for both tools. You can, of course, replace them with your own configurations.

Publishing

To publish a new version, run pnpm release. This will:

  • Run the tests
  • Update the changelog
  • Publish the package to npmjs.org
  • Push the changes to the repository

Private publishing

If you want to publish a private package to npm.laioutr.cloud, you need to:

  1. Make sure you have a .npmrc with your private npm registry token.
  2. Add this line to the root of the package.json file: "publishConfig": { "registry": "https://npm.laioutr.cloud/" }
  3. Make sure your package-name follows the @laioutr-org/<organization-slug>__<package-name> format.

After that you can run pnpm release to publish the package to npm.laioutr.cloud.

More information for publishing can be found in the NPM Guide.

Contribution

Follow the setup guide to get started.