'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(`<title>${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 `