Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions .github/workflows/check-llms.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Check llms.txt

# Guards against llms.txt drifting out of sync with the site product menu.
# Runs the dependency-free scripts/check-llms-links.mjs; fails the check if a
# product in documents.ts has no matching link in llms.txt. Kept separate from
# the deploy workflow so it never blocks a production deploy.

on:
pull_request:
paths:
- "docs/.vuepress/public/llms.txt"
- "docs/.vuepress/config-client/documents.ts"
- "scripts/check-llms-links.mjs"
- ".github/workflows/check-llms.yml"
push:
branches:
- master
paths:
- "docs/.vuepress/public/llms.txt"
- "docs/.vuepress/config-client/documents.ts"
- "scripts/check-llms-links.mjs"

jobs:
check-llms:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"

- name: Check llms.txt is in sync with the product menu
run: node scripts/check-llms-links.mjs
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,6 @@ docs/.vuepress/dist
docs/.vuepress/.cache
docs/.vuepress/.temp
package-lock.json

# Generated at build time by scripts/generate-llms-full.mjs
docs/.vuepress/public/llms-full.txt
File renamed without changes.
41 changes: 41 additions & 0 deletions docs/.vuepress/public/llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# TuxCare Documentation

> TuxCare provides automated Linux patch management, live kernel patching, extended lifecycle support (ELS) for end-of-life operating systems, libraries, runtimes and applications, and SecureChain — verified, signed, continuously patched open-source packages. This site (docs.tuxcare.com) is the official product documentation. Each link below points to a product area; append the path to https://docs.tuxcare.com to read it.

The full text of every documentation page, concatenated into a single file, is available at [https://docs.tuxcare.com/llms-full.txt](https://docs.tuxcare.com/llms-full.txt).

## Products

- [TuxCare](https://docs.tuxcare.com/tuxcare/): Overview of TuxCare services that automate, simplify, and enhance Linux operations.
- [TuxCare Radar](https://docs.tuxcare.com/radar/): Vulnerability risk assessment and prioritization that reveals real-world risk beyond conventional scanner scores.
- [TuxCare Enterprise Support](https://docs.tuxcare.com/enterprise-support-for-almalinux/): Extended support and security fixes for AlmaLinux and Rocky Linux.

## Live patching

- [KernelCare](https://docs.tuxcare.com/live-patching-services/): Live kernel patching — security patches and bugfixes with no reboot.
- [KernelCare for IoT](https://docs.tuxcare.com/kernelcare-for-iot/): Live patching for ARM64 embedded/IoT systems.
- [ePortal](https://docs.tuxcare.com/eportal/): Web management console for KernelCare Enterprise live patch management.
- [ePortal API](https://docs.tuxcare.com/eportal-api/): API reference for patch management and ePortal automation.

## Extended Lifecycle Support (ELS)

- [ELS for Operating Systems](https://docs.tuxcare.com/els-for-os/): Post-EOL security support for end-of-life Linux distributions.
- [ELS for Libraries](https://docs.tuxcare.com/els-for-libraries/): Extended security fixes for open-source libraries.
- [ELS for Runtimes](https://docs.tuxcare.com/els-for-runtimes/): Extended support for language runtimes (PHP, Python, Node.js, etc.).
- [ELS for Applications](https://docs.tuxcare.com/els-for-applications/): Security patches for end-of-life open-source applications.

## SecureChain

- [SecureChain for Open Source](https://docs.tuxcare.com/securechain/): Verified, signed, continuously patched open-source packages from a TuxCare-managed registry — drop-in replacements that extend coverage beyond upstream EOL.
- [SecureChain JavaScript Ecosystem Guide](https://docs.tuxcare.com/securechain/javascript/): Connect to the SecureChain npm registry, authenticate, install dependencies, and troubleshoot resolution.
- [Managing the SecureChain Repository](https://docs.tuxcare.com/securechain/managing-securechain-repository/): Upgrade installed SecureChain packages to newer releases across ecosystems.

## Account & reference

- [Subscription Management Portal](https://docs.tuxcare.com/tuxcare-cln/): Manage licenses and subscriptions for TuxCare products and services.
- [Service Descriptions](https://docs.tuxcare.com/service-descriptions/): Service-level and technical support details.

## Optional

- [TuxCare product site](https://tuxcare.com/): Marketing/product overview and contact.
- [CVE Tracker](https://tuxcare.com/cve-tracker/): Track vulnerability fixes and update status across TuxCare-supported packages.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"type": "module",
"scripts": {
"docs:dev": "vuepress dev docs",
"docs:build": "vuepress build docs",
"docs:build": "node scripts/generate-llms-full.mjs && vuepress build docs",
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
Expand Down
86 changes: 86 additions & 0 deletions scripts/check-llms-links.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Drift guard for llms.txt.
*
* The curated llms.txt is hand-maintained. Its one real staleness risk is a
* product being added to (or renamed in) the site's product menu without a
* matching entry in llms.txt. This check compares the product links in
* docs/.vuepress/config-client/documents.ts (the source of truth for the
* product menu) against llms.txt and fails if any product is missing.
*
* It intentionally does NOT require the reverse: llms.txt may include extra
* links (SecureChain subsections, the Optional marketing/CVE-tracker links)
* that are not products in documents.ts.
*
* No dependencies — runs on plain Node built-ins.
*/
import { existsSync, readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const DOCS = join(REPO, "docs");
const DOCUMENTS = join(DOCS, ".vuepress", "config-client", "documents.ts");
const LLMS = join(DOCS, ".vuepress", "public", "llms.txt");
const SITE = "https://docs.tuxcare.com";

const documentsSrc = readFileSync(DOCUMENTS, "utf-8");
const llms = readFileSync(LLMS, "utf-8");

// Extract each product's title + link from the documents.ts array literal.
const productRe = /title:\s*"([^"]+)"[\s\S]*?link:\s*"([^"]+)"/g;
const products = [...documentsSrc.matchAll(productRe)].map((m) => ({
title: m[1],
link: m[2],
}));

if (products.length === 0) {
console.error(
`[check-llms-links] Could not parse any products from ${DOCUMENTS}. ` +
`Has the file format changed?`,
);
process.exit(1);
}

const missing = products.filter(({ link }) => !llms.includes(SITE + link));

if (missing.length > 0) {
console.error(
"[check-llms-links] llms.txt is out of sync with the site product menu.\n" +
"The following products in docs/.vuepress/config-client/documents.ts " +
"have no matching link in docs/.vuepress/public/llms.txt:\n",
);
for (const { title, link } of missing) {
console.error(` - ${title} -> ${SITE + link}`);
}
console.error(
"\nAdd each missing product to llms.txt (with a curated one-line " +
"description), then re-run this check.",
);
process.exit(1);
}

// Validity: every docs.tuxcare.com page link in llms.txt must resolve to a
// real page on disk. Catches typos and links left stale when a product is
// renamed or removed in documents.ts. Page URLs end in "/"; the llms-full.txt
// file link (and any other non-page link) is skipped.
const pageRe = /https:\/\/docs\.tuxcare\.com(\/[\w/-]*\/)/g;
const broken = [...new Set([...llms.matchAll(pageRe)].map((m) => m[1]))].filter(
(path) => !existsSync(join(DOCS, path, "README.md")),
);

if (broken.length > 0) {
console.error(
"[check-llms-links] llms.txt contains links to pages that do not exist " +
"on disk (typo, or a product renamed/removed in documents.ts?):\n",
);
for (const path of broken) {
console.error(` - ${SITE + path} -> docs${path}README.md (not found)`);
}
process.exit(1);
}

console.log(
`[check-llms-links] OK — all ${products.length} product links present, ` +
`every page link resolves on disk.`,
);
123 changes: 123 additions & 0 deletions scripts/generate-llms-full.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* Generate docs/.vuepress/public/llms-full.txt from the documentation source.
*
* Runs before `vuepress build` (see the `docs:build` script in package.json),
* so the file is always regenerated from the current docs and copied to the
* site root by VuePress (public/ -> dist/). The file is git-ignored; it exists
* only in the build output.
*
* Each page is emitted under a `Source:` header with its published URL.
* Cleaning applied:
* - strip YAML frontmatter
* - strip HTML comments
* - strip <script>/<style> blocks
* - strip CamelCase VuePress component tags (keep inner prose; salvage
* literal code snippets from CodeTabs/TableTabs `content:` attributes)
* - strip VuePress container markers (:::tip / :::warning / :::), keep body
* - preserve ALL-CAPS placeholder tags (<YOUR_TOKEN>, <EOF>, ...)
* - collapse runs of blank lines
*/
import { readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";

const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = resolve(__dirname, "..");
const DOCS = join(REPO, "docs");
const SITE = "https://docs.tuxcare.com";
const OUT = join(DOCS, ".vuepress", "public", "llms-full.txt");
const EXCLUDE_DIRS = new Set([".vuepress", "_pages-backup", "node_modules"]);

// CamelCase component name: starts uppercase, contains at least one lowercase.
const COMP = "[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*";
const frontmatterRe = /^---\n[\s\S]*?\n---\n/;
const commentRe = /<!--[\s\S]*?-->/g;
const scriptRe = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
const styleRe = /<style\b[^>]*>[\s\S]*?<\/style>/gi;
const selfCloseRe = new RegExp(`<${COMP}\\b[^>]*?/>`, "g");
const openRe = new RegExp(`<${COMP}\\b[^>]*?>`, "g");
const closeRe = new RegExp(`</${COMP}\\s*>`, "g");
const templateRe = /<\/?template\b[^>]*>/g;
// A CodeTabs/TableTabs `content:` is either an inline `code` literal or a
// variable defined in the page's <script setup>; resolve both.
const scriptVarRe = /(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*`([^`]*)`/g;
const tabRe = /title:\s*'([^']+)'\s*,\s*content:\s*(`[^`]*`|[A-Za-z_$][\w$]*)/g;
// VuePress container markers: an opening `:::tip Title`, a bare `:::` fence,
// and custom-container variants. Match the marker line only, keep the body.
const containerRe = /^[ \t]*:::+.*$/gm;

/** Emit each CodeTabs/TableTabs tab as a titled code block, resolving
* `content:` variable refs against the page's <script> definitions. */
function salvage(tagText, vars) {
const out = [];
for (const [, title, ref] of tagText.matchAll(tabRe)) {
const code = ref.startsWith("`") ? ref.slice(1, -1) : vars.get(ref);
if (code == null) continue;
out.push(`${title}:`, "```\n" + code.trim() + "\n```");
}
return out.join("\n");
}

function clean(text) {
// Capture <script> variable bodies before the script block is stripped.
const vars = new Map([...text.matchAll(scriptVarRe)].map((m) => [m[1], m[2]]));
text = text.replace(frontmatterRe, "");
text = text.replace(commentRe, "");
text = text.replace(scriptRe, "");
text = text.replace(styleRe, "");
text = text.replace(selfCloseRe, (m) => salvage(m, vars));
text = text.replace(openRe, "");
text = text.replace(closeRe, "");
text = text.replace(templateRe, "");
text = text.replace(containerRe, "");
text = text.replace(/\n{3,}/g, "\n\n");
return text.trim();
}

function urlFor(relPath) {
const d = dirname(relPath);
return SITE + "/" + (d === "." ? "" : d.replace(/\\/g, "/") + "/");
}

/** Recursively collect README.md files, skipping excluded/dot directories. */
function collect(dir, acc = []) {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
if (statSync(full).isDirectory()) {
if (EXCLUDE_DIRS.has(entry) || entry.startsWith(".")) continue;
collect(full, acc);
} else if (entry === "README.md") {
acc.push(full);
}
}
return acc;
}

const pages = collect(DOCS)
.map((full) => ({ url: urlFor(relative(DOCS, full)), path: full }))
.sort((a, b) => a.url.localeCompare(b.url));

const parts = [
"# TuxCare Documentation — Full Text",
"",
"> Full text of the TuxCare product documentation (docs.tuxcare.com), " +
"concatenated for LLM consumption. Each section below is one documentation " +
"page, preceded by its source URL. Interactive/tabbed content has been " +
"flattened; visit the source URL for the rendered page.",
"",
];

let included = 0;
for (const { url, path } of pages) {
const body = clean(readFileSync(path, "utf-8"));
if (!body) continue;
included += 1;
parts.push("\n\n" + "=".repeat(80));
parts.push(`Source: ${url}`);
parts.push("=".repeat(80) + "\n");
parts.push(body);
}

writeFileSync(OUT, parts.join("\n") + "\n");
console.log(`[generate-llms-full] pages: ${included}, bytes: ${readFileSync(OUT).length}`);
Loading