'use strict';
/**
* openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).
*
* const seo = require('openvibe-shared/seo');
* seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })
* seo.sitemapXml([{ loc, lastmod, videos? }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})
*
* Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at
* 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD
* serialised safely (no `` break-outs), sitemaps with absolute URLs only, robots.txt that
* welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so
* language-model crawlers get a clean map of the site.
*/
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */
function clip(text, max) {
const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
if (t.length <= max) return t;
const cut = t.slice(0, max - 1);
const at = cut.lastIndexOf(' ');
return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';
}
function absolute(url, base) {
if (!url) return '';
try { return new URL(url, base || undefined).toString(); } catch { return String(url); }
}
/** JSON-LD script tag; `<` is escaped so content can never close the script element. */
function jsonLdTag(obj) {
return ``;
}
/**
* The whole
SEO block as an HTML string.
* @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),
* siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,
* themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),
* titleSuffix (appended when it fits and the title does not already name the site), noTitle (skip when the page has its own)
*/
function headTags(o = {}) {
let title = String(o.title || o.siteName || 'OpenVibe');
// The suffix (" · OpenVibe.Work") is left off a title that already names the site: "What shipped on OpenVibe.Work
// · OpenVibe.Work" was every update log's title (11-02).
const named = String(o.titleSuffix || '').replace(/^[\s·|—–-]+/, '').trim().toLowerCase();
if (o.titleSuffix && !(named && title.toLowerCase().includes(named)) && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;
title = clip(title, 60);
const description = clip(o.description || '', 160);
const canonical = absolute(o.canonical);
const image = o.image ? absolute(o.image, canonical) : '';
const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');
const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';
const out = [];
if (!o.noTitle) out.push(`${esc(title)}`);
if (description) out.push(``);
if (keywords) out.push(``);
out.push(``);
if (o.author) out.push(``);
if (o.themeColor) out.push(``);
if (canonical) out.push(``);
// Every page's bar asks openvibe.network (the Frame, sign-in, the menu): open that connection early (4.135.0).
if (o.preconnect !== false && !/^https:\/\/openvibe\.network(\/|$)/.test(canonical || '')) out.push('');
for (const a of o.alternates || []) if (a && a.href) out.push(``);
if (o.prev) out.push(``);
if (o.next) out.push(``);
out.push(``);
if (o.siteName) out.push(``);
out.push(``);
if (description) out.push(``);
if (canonical) out.push(``);
out.push(``);
if (image) {
out.push(``);
if (o.imageAlt) out.push(``);
}
out.push(``);
if (o.twitterSite) out.push(``);
out.push(``);
if (description) out.push(``);
if (image) out.push(``);
const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]).filter(Boolean) : [];
// A WebSite names its publisher by @id (OpenVibe's Organization). A page with a WebSite but no Organization gets the
// node too, so the reference resolves on every site and search engines see one OpenVibe across the network.
const types = (x) => [].concat(x && x['@type'] || []);
if (ld.some((x) => types(x).includes('WebSite')) && !ld.some((x) => types(x).includes('Organization') || x['@id'] === ORG['@id'])) ld.push(jsonLd.organization());
for (const item of ld) out.push(jsonLdTag(item));
return out.join('\n');
}
// OpenVibe, said so search engines and AI cannot take it for something else (the owner, 2026-10-28: Google's AI Overview
// for "whois openvibe" described an unrelated "Openvibe" mobile app): what it is, its other names, what it is not, and its
// sites. Network's home page carries the same Organization (#org); every site's pages name it as their publisher.
const ORG = {
'@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', alternateName: ['OpenVibe Network', 'OpenVibers'],
url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png',
description: 'OpenVibe is an open-source, community-run network of websites that share one free account: live streaming from the browser (OpenVibe.Live), more than 180 online tools (OpenVibe.Tools), chat and the Vibe assistant (OpenVibe.Chat), games, a wiki, news, reviews, deals and an open developer platform.',
disambiguatingDescription: 'The open-source network of websites at openvibe.network, made by OpenVibers; not the Openvibe mobile social-media aggregator app.',
sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj', 'https://openvibe.live', 'https://openvibe.tools', 'https://openvibe.chat', 'https://openvibe.games',
'https://openvibe.media', 'https://openvibe.video', 'https://openvibe.community', 'https://openvibe.space', 'https://openvibe.wiki', 'https://openvibe.blog',
'https://openvibe.news', 'https://openvibe.services', 'https://openre.stream'],
};
const jsonLd = {
organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },
/** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */
website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {
const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };
if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };
return o;
},
webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {
return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };
},
/** A web tool. No price/offer fields: the platform makes no cost claims. */
softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {
return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
},
breadcrumbs(items = []) {
return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };
},
itemList(name, items = [], { url } = {}) {
return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };
},
faq(pairs = []) {
return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };
},
howTo({ name, description, steps = [] } = {}) {
return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };
},
article({ headline, url, description, author, datePublished, dateModified, image } = {}) {
return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
},
video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {
return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };
},
videoObject(options = {}) { return jsonLd.video(options); },
product({ name, url, description, image, sku, brand, offers, aggregateRating, review } = {}) {
return { '@context': 'https://schema.org', '@type': 'Product', name, url, description,
...(image ? { image } : {}), ...(sku ? { sku } : {}),
...(brand ? { brand: typeof brand === 'string' ? { '@type': 'Brand', name: brand } : brand } : {}),
...(offers ? { offers } : {}), ...(aggregateRating ? { aggregateRating } : {}), ...(review ? { review } : {}) };
},
review({ name, url, reviewBody, author, datePublished, itemReviewed, rating, bestRating = 5, worstRating = 1 } = {}) {
return { '@context': 'https://schema.org', '@type': 'Review', name, url, reviewBody,
...(author ? { author: typeof author === 'string' ? { '@type': 'Person', name: author } : author } : {}),
...(datePublished ? { datePublished } : {}), ...(itemReviewed ? { itemReviewed } : {}),
...(rating != null ? { reviewRating: { '@type': 'Rating', ratingValue: rating, bestRating, worstRating } } : {}) };
},
aggregateRating({ ratingValue, ratingCount, reviewCount, bestRating = 5, worstRating = 1 } = {}) {
return { '@context': 'https://schema.org', '@type': 'AggregateRating', ratingValue,
...(ratingCount != null ? { ratingCount } : {}), ...(reviewCount != null ? { reviewCount } : {}), bestRating, worstRating };
},
imageGallery(images = []) {
return images.map((image) => {
const item = typeof image === 'string' ? { url: image } : image;
return { '@context': 'https://schema.org', '@type': 'ImageObject', url: item.url,
...(item.name ? { name: item.name } : {}), ...(item.caption ? { caption: item.caption } : {}),
...(item.width != null ? { width: item.width } : {}), ...(item.height != null ? { height: item.height } : {}) };
});
},
/** An event that really is happening at a time the page shows. Only the parts the page has are written. */
event({ name, startDate, endDate, url, description, organizer, location, eventStatus, eventAttendanceMode, image } = {}) {
return { '@context': 'https://schema.org', '@type': 'Event', name, startDate,
...(endDate ? { endDate } : {}), ...(description ? { description } : {}),
...(eventStatus ? { eventStatus } : {}), ...(eventAttendanceMode ? { eventAttendanceMode } : {}),
...(location ? { location } : {}),
...(organizer ? { organizer: typeof organizer === 'string' ? { '@type': 'Organization', name: organizer } : organizer } : {}),
...(image ? { image } : {}), ...(url ? { url } : {}) };
},
/** A public job listing. `description` is the page's own excerpt; nothing is invented for the fields it omits. */
jobPosting({ title, url, description, datePosted, validThrough, hiringOrganization, jobLocationType, employmentType, jobLocation, baseSalary, identifier, directApply } = {}) {
return { '@context': 'https://schema.org', '@type': 'JobPosting', title,
...(description ? { description } : {}), ...(datePosted ? { datePosted } : {}), ...(validThrough ? { validThrough } : {}),
...(hiringOrganization ? { hiringOrganization: typeof hiringOrganization === 'string' ? { '@type': 'Organization', name: hiringOrganization } : hiringOrganization } : {}),
...(jobLocationType ? { jobLocationType } : {}), ...(employmentType ? { employmentType } : {}),
...(jobLocation ? { jobLocation } : {}), ...(baseSalary ? { baseSalary } : {}),
...(identifier ? { identifier } : {}), ...(directApply != null ? { directApply } : {}), ...(url ? { url } : {}) };
},
/** A recipe page's own ingredients and steps. Steps may be strings or ready HowToStep objects. */
recipe({ name, url, description, image, recipeIngredient, recipeInstructions, recipeYield, totalTime, author, datePublished } = {}) {
return { '@context': 'https://schema.org', '@type': 'Recipe', name,
...(description ? { description } : {}), ...(image ? { image } : {}),
...(recipeIngredient ? { recipeIngredient } : {}),
...(recipeInstructions ? { recipeInstructions: recipeInstructions.map((s) => (typeof s === 'string' ? { '@type': 'HowToStep', text: s } : s)) } : {}),
...(recipeYield ? { recipeYield } : {}), ...(totalTime ? { totalTime } : {}),
...(author ? { author: typeof author === 'string' ? { '@type': 'Organization', name: author } : author } : {}),
...(datePublished ? { datePublished } : {}), ...(url ? { url } : {}) };
},
/** An open dataset a page publishes. `license` only when the site names one. */
dataset({ name, url, description, license, creator, keywords, distribution } = {}) {
return { '@context': 'https://schema.org', '@type': 'Dataset', name,
...(description ? { description } : {}), ...(license ? { license } : {}),
...(creator ? { creator: typeof creator === 'string' ? { '@type': 'Organization', name: creator } : creator } : {}),
...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}),
...(distribution ? { distribution } : {}), ...(url ? { url } : {}) };
},
};
const isAbs = (u) => typeof u === 'string' && /^https?:\/\//i.test(u);
/**
* One of a video sitemap (Google's sitemap-video/1.1), or '' when a required part is missing: a thumbnail,
* a title, a description and the video itself (content_loc, the file, or player_loc, a page or embed that plays it).
* Duration is whole seconds (1–28,800, longer is left out); publication_date an ISO date or date-time.
*/
function videoXml(v) {
if (!v || !isAbs(v.thumbnail_loc) || !v.title || !v.description || !(isAbs(v.content_loc) || isAbs(v.player_loc))) return '';
const parts = [`${xml(v.thumbnail_loc)}`,
`${xml(String(v.title).slice(0, 100))}`,
`${xml(String(v.description).slice(0, 2048))}`];
if (isAbs(v.content_loc)) parts.push(`${xml(v.content_loc)}`);
if (isAbs(v.player_loc)) parts.push(`${xml(v.player_loc)}`);
const d = Math.round(Number(v.duration));
if (d >= 1 && d <= 28800) parts.push(`${d}`);
if (v.view_count != null && Number.isFinite(Number(v.view_count))) parts.push(`${Math.max(0, Math.floor(Number(v.view_count)))}`);
if (v.publication_date) parts.push(`${xml(String(v.publication_date))}`);
if (v.family_friendly === false) parts.push('no');
if (v.uploader) {
const info = isAbs(v.uploader_info) ? ` info="${xml(v.uploader_info)}"` : '';
parts.push(`${xml(String(v.uploader).slice(0, 255))}`);
}
if (v.live != null) parts.push(`${v.live ? 'yes' : 'no'}`);
return `${parts.join('')}`;
}
/**
* urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url], videos?: [video] }],
* absolute http(s) only. A video is { thumbnail_loc, title, description, content_loc and/or player_loc, duration?,
* view_count?, publication_date?, family_friendly?, uploader?, uploader_info?, live? } (see videoXml); the video
* namespace is declared only when a video is listed.
*/
function sitemapXml(urls = []) {
let videos = false;
const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
const parts = [`${xml(u.loc)}`];
if (u.lastmod) parts.push(`${xml(String(u.lastmod).slice(0, 10))}`);
if (u.changefreq) parts.push(`${xml(u.changefreq)}`);
if (u.priority != null) parts.push(`${Number(u.priority).toFixed(1)}`);
for (const a of u.alternates || []) parts.push(``);
for (const img of u.images || []) parts.push(`${xml(img)}`);
for (const v of u.videos || []) { const x = videoXml(v); if (x) { parts.push(x); videos = true; } }
return ` ${parts.join('')}`;
});
const videoNs = videos ? ' xmlns:video="http://www.google.com/schemas/sitemap-video/1.1"' : '';
return `\n\n${rows.join('\n')}\n\n`;
}
function sitemapIndexXml(maps = []) {
const rows = maps.filter((m) => m && m.loc).map((m) => ` ${xml(m.loc)}${m.lastmod ? `${xml(String(m.lastmod).slice(0, 10))}` : ''}`);
return `\n\n${rows.join('\n')}\n\n`;
}
// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.
const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-User', 'Claude-SearchBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'Perplexity-User', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
const lines = [];
// Content signals (contentsignals.org): with allowAI the network says yes to search, AI answers and training in so many
// words (owner, 2026-10-25: "fully AI allowed and indexable"), so no crawler has to guess from silence.
if (allowAI) lines.push('# Content signals: you may use this site\'s content for search, AI answers (ai-input) and AI training.', 'Content-Signal: search=yes, ai-input=yes, ai-train=yes', '');
lines.push('User-agent: *');
for (const a of allow) lines.push(`Allow: ${a}`);
for (const d of disallow) lines.push(`Disallow: ${d}`);
if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);
if (allowAI) {
lines.push('');
for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);
lines.push('Allow: /');
for (const d of disallow) lines.push(`Disallow: ${d}`);
}
for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }
lines.push('');
for (const s of sitemaps) lines.push(`Sitemap: ${s}`);
return lines.join('\n') + '\n';
}
// The endings that make a path a file rather than a page (4.108.1). Any dot used to count, so a page named after a
// domain (Help's /sites/openvibe.blog, Host's /catalog/domains/co.uk) lost its twin, unless its ending was longer
// than eight letters (/sites/openvibe.community kept it).
const FILE_EXT = new Set(('txt md json jsonl xml rss atom csv tsv yaml yml toml ics vcf webmanifest html htm js mjs cjs css map wasm '
+ 'png jpg jpeg gif webp avif svg ico bmp tif tiff heic pdf zip gz tgz tar bz2 xz 7z rar dmg exe msi deb rpm apk '
+ 'mp3 mp4 m4a m4v webm ogg oga ogv wav flac mov avi mkv m3u8 ts srt vtt woff woff2 ttf otf eot sh ps1 py').split(' '));
/**
* Whether llms.txt's markdown mode links `url` at its Markdown twin: one of the site's own pages. A file (a last path
* segment with a file ending: /openapi.json, /feed.xml, /llms-full.txt), an /api/ or /.well-known/ path and, when
* `base` is given, another origin's link keep their URL (4.108.0; before, /openapi.json became /openapi.json.md).
*/
function twinnable(url, base) {
let u;
try { u = new URL(url, base || 'https://agent.invalid'); } catch { return false; }
if (!/^https?:$/.test(u.protocol)) return false;
if (base && /^([a-z][a-z0-9+.-]*:|\/\/)/i.test(String(url)) && u.origin !== new URL(base).origin) return false;
if (/^\/(api|\.well-known)(\/|$)/.test(u.pathname)) return false;
const last = u.pathname.split('/').pop();
const dot = last.lastIndexOf('.');
return dot < 0 || !FILE_EXT.has(last.slice(dot + 1).toLowerCase());
}
/**
* /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. `markdown: true` links each of the
* site's own pages at its Markdown twin (/hot → /hot.md, / → /index.md); `base` (the site's origin) says which absolute
* links are its own. A page with no twin takes `html: true` and keeps its URL, marked "(HTML)". `optional` is the
* llmstxt.org "## Optional" section: links an agent may skip when it needs a shorter context.
*/
function llmsTxt({ name, summary, details, sections = [], markdown = false, optional = [], base } = {}) {
const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
if (details) out.push(String(details).trim(), '');
const href = (l) => (markdown && !l.html && twinnable(l.url, base) ? require('./agent-layer').twinUrl(l.url) : l.url);
for (const sec of [...sections, ...(optional.length ? [{ title: 'Optional', links: optional }] : [])]) {
if (!sec || !sec.links || !sec.links.length) continue;
out.push(`## ${sec.title}`, '');
for (const l of sec.links) out.push(`- [${l.title}](${href(l)})${l.html ? ' (HTML)' : ''}${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
out.push('');
}
return out.join('\n');
}
/** Preserve full-text paragraphs while enforcing a character limit, including the ellipsis. */
function limitText(value, max) {
const text = String(value == null ? '' : value);
if (max == null || !Number.isFinite(max)) return text;
const size = Math.max(0, Math.floor(max));
if (text.length <= size) return text;
return size ? text.slice(0, size - 1).trimEnd() + '…' : '';
}
const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ' };
/** Plain text from HTML: scripts, styles and comments dropped, block ends kept as line breaks. */
function htmlText(html) {
return String(html == null ? '' : html)
.replace(//g, '')
.replace(/<(script|style|template)\b[\s\S]*?<\/\1\s*>/gi, '')
.replace(/ |<\/(li|dt|dd|tr)\s*>/gi, '\n')
.replace(/<\/(p|div|section|article|header|footer|main|aside|nav|ul|ol|dl|table|h[1-6]|blockquote|pre|figure)\s*>/gi, '\n\n')
.replace(/<[^>]*>/g, '')
.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, e) => {
if (e[0] !== '#') return ENTITIES[e.toLowerCase()] || m;
const code = e[1] === 'x' || e[1] === 'X' ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10);
return code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : m;
})
.split('\n').map((line) => line.replace(/\s+/g, ' ').trim()).join('\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
/** One /llms-full.txt page: `### title`, `URL: …` and its full text (`text`, or `html` stripped to text). */
function llmsPage(page, base) {
const url = absolute(page.url, base);
const text = page.text != null ? String(page.text).replace(/\r\n?/g, '\n').trim() : htmlText(page.html);
return `\n### ${page.title || ''}\n${url ? `\nURL: ${url}\n` : ''}${text ? `\n${text}\n` : ''}`;
}
/**
* /llms-full.txt: the llms.txt header followed by full, plain-text sections. A section with
* `pages: [{ title, url, text | html }]` renders `## title` then every page in input order, never
* clipped; `maxBytes` stops before the first page that would pass it and ends with a
* `(truncated: N more pages at /llms.txt)` line. A section with `url`/`body` renders as in 2.4.0.
*/
function llmsFull({ site, summary, base, sections = [], maxChars, maxTotal, maxBytes } = {}) {
const name = site && typeof site === 'object' ? site.name || site.title : site;
base = base || (site && typeof site === 'object' ? site.url : site);
let out = llmsTxt({ name, summary });
let left = 0;
for (const section of sections) {
if (!section) continue;
if (Array.isArray(section.pages)) {
let heading = `\n## ${section.title || ''}\n`;
for (const page of section.pages) {
if (!page) continue;
const chunk = heading + llmsPage(page, base);
if (left || (maxBytes != null && Buffer.byteLength(out + chunk) > maxBytes)) { left++; continue; }
out += chunk;
heading = '';
}
continue;
}
if (left) continue;
const url = absolute(section.url, base);
const body = limitText(String(section.body == null ? '' : section.body).trim(), section.maxChars == null ? maxChars : section.maxChars);
out += `\n## ${section.title || ''}\n\n${url}\n\n${body}\n`;
}
if (left) out += `\n(truncated: ${left} more page${left === 1 ? '' : 's'} at ${absolute('/llms.txt', base)})\n`;
return limitText(out, maxTotal);
}
/**
* AI-readable page summary. As a string (`${seo.pageSummary(…)}`, `String(…)`) it is the HTML:
* `` (clipped to 160 like the description), a WebPage JSON-LD tag and, when
* `facts` (strings) are given, a `