@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
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 itsSitemap:line resolved against the requesting host.
Plus, for AI agents and other Markdown-preferring clients:
- Markdown twins —
<path>.mdfor every page (/index.mdfor the home page), converted from the rendered HTML with mdream. Header and footer sections and anything markeddata-markdown-ignoreare 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 a307. A noindex page's twin carries the same directive asX-Robots-Tag, and every twin on a deployment or market that isn't indexable (see below) carriesX-Robots-Tag: noindex, nofollowoutright, 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">andrel="describedby"→/llms.txtin the page head and theLinkheader.
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 unheadpnpm-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 tokenecommerce/product-detail-pageindebecomesecommerce-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=yContent-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/loginEach 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 currentH3Event.token— the page type token for this source, e.g.'ecommerce/product-detail-page'.nullon 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 entriesurlswas 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 withurls: entries dropped along the way (flaggednoindex, missing required params, or otherwise unfillable) never reachurls, soentriesis 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/replacectx.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 beforerobots.txtis 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 renderedrobots.txtcontext, 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.mdtwin is rendered; setctx.sourceto 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; reassignctx.markdownto 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.urlunset on a multi-market project. If it's set — whether directly innuxt.config.tsor 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 whensite.urlis set alongside more than one configured host, but it does not unset it for you. indexable: 'always'outranksenvironment, 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
startCursornever pulls more thanentriesPerRequestentries — and its sitemap can land fewer URLs than that, since anoindexor 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 — returningpaginate(fn, startCursor)is what makes a page type fully enumerable across passes. When a handler ignoresstartCursorinstead, this module falls back to a single bounded read of up toentriesPerRequestentries and logs a warning naming the page type, so raisingentriesPerRequestis 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 —
entriesPerRequestentries 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 thanentriesPerRequestneeds several requests, from crawlers or your own warm-up, before its sitemap is complete.entriesPerRequestis 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 installnpx @laioutr/cli project fetch-rc --project <organization slug>/<project slug> --secret <project secret key>- This will load thelaioutrrc.jsonfile 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:
- Make sure you have a
.npmrcwith your private npm registry token. - Add this line to the root of the
package.jsonfile:"publishConfig": { "registry": "https://npm.laioutr.cloud/" } - 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.
