From 50472f64612525d5cebcdcc0a03a9441326bd4f1 Mon Sep 17 00:00:00 2001 From: Cory LaViska Date: Fri, 19 Dec 2025 12:17:00 -0500 Subject: [PATCH] add and enable cem plugin for llms.txt --- cspell.json | 3 + package-lock.json | 1 + .../webawesome/custom-elements-manifest.js | 8 + .../docs/docs/resources/changelog.md | 1 + packages/webawesome/package.json | 1 + packages/webawesome/scripts/llms.js | 257 ++++++++++++++++++ 6 files changed, 271 insertions(+) create mode 100644 packages/webawesome/scripts/llms.js diff --git a/cspell.json b/cspell.json index 6c381b4df..f97829712 100644 --- a/cspell.json +++ b/cspell.json @@ -114,6 +114,9 @@ "listbox", "listitem", "litelement", + "llm", + "llms", + "llmstxt", "longform", "lowercasing", "Lucide", diff --git a/package-lock.json b/package-lock.json index 61dcacd43..c4485d1e6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14040,6 +14040,7 @@ "@wc-toolkit/jsx-types": "^1.3.0", "eleventy-plugin-git-commit-date": "^0.1.3", "esbuild": "^0.25.11", + "gray-matter": "^4.0.3", "npm-check-updates": "^19.1.2" }, "engines": { diff --git a/packages/webawesome/custom-elements-manifest.js b/packages/webawesome/custom-elements-manifest.js index c58530abd..53b9f596d 100644 --- a/packages/webawesome/custom-elements-manifest.js +++ b/packages/webawesome/custom-elements-manifest.js @@ -7,6 +7,7 @@ import fs from 'fs'; import * as path from 'node:path'; import { pascalCase } from 'pascal-case'; import * as url from 'url'; +import { llmsTxtPlugin } from './scripts/llms.js'; const __dirname = url.fileURLToPath(new URL('.', import.meta.url)); const packageData = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8')); @@ -188,6 +189,13 @@ export default { }, }), + // Generate llms.txt + llmsTxtPlugin({ + outdir, + docsDir: path.join(__dirname, 'docs'), + baseUrl: 'https://webawesome.com', + }), + // // TODO - figure out why this broke when events were updated // diff --git a/packages/webawesome/docs/docs/resources/changelog.md b/packages/webawesome/docs/docs/resources/changelog.md index 012763d8d..cae3d72ff 100644 --- a/packages/webawesome/docs/docs/resources/changelog.md +++ b/packages/webawesome/docs/docs/resources/changelog.md @@ -13,6 +13,7 @@ Components with the Experimental badge sh ## Next +- Added llms.txt to assist AI agents with using Web Awesome [discuss:1100] - Fixed a bug in `` that prevented the listbox from opening when options were preselected [issue:1883] ## 3.1.0 diff --git a/packages/webawesome/package.json b/packages/webawesome/package.json index 36f9bf483..9e2e12ed1 100644 --- a/packages/webawesome/package.json +++ b/packages/webawesome/package.json @@ -92,6 +92,7 @@ "@wc-toolkit/jsx-types": "^1.3.0", "eleventy-plugin-git-commit-date": "^0.1.3", "esbuild": "^0.25.11", + "gray-matter": "^4.0.3", "npm-check-updates": "^19.1.2" } } diff --git a/packages/webawesome/scripts/llms.js b/packages/webawesome/scripts/llms.js new file mode 100644 index 000000000..65aa06d2a --- /dev/null +++ b/packages/webawesome/scripts/llms.js @@ -0,0 +1,257 @@ +import fs from 'fs'; +import matter from 'gray-matter'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { getAllComponents } from './shared.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** Removes newlines from text to keep llms.txt formatting clean. */ +function removeNewlines(str) { + return str ? str.replace(/\n/g, ' ').trim() : ''; +} + +/** Loads front-matter from all component markdown files. */ +function loadAllFrontMatter(components, docsDir) { + const cache = new Map(); + + for (const component of components) { + const componentName = component.tagName.replace(/^wa-/, ''); + const mdPath = path.join(docsDir, 'docs/components', `${componentName}.md`); + + if (fs.existsSync(mdPath)) { + try { + const content = fs.readFileSync(mdPath, 'utf-8'); + const { data } = matter(content); + cache.set(component.tagName, data); + } catch { + // Skip if parsing fails + } + } + } + + return cache; +} + +/** Generates the API reference section for a single component. */ +function generateComponentApiSection(component, frontMatterCache, baseUrl) { + const lines = []; + const frontMatter = frontMatterCache.get(component.tagName); + const componentSlug = component.tagName.replace(/^wa-/, ''); + const description = removeNewlines(frontMatter?.description || component.summary || ''); + + lines.push(`#### \`<${component.tagName}>\``); + lines.push(''); + lines.push(`**Description:** ${description || 'No description available.'}`); + lines.push(''); + lines.push(`**Documentation:** ${baseUrl}/docs/components/${componentSlug}`); + lines.push(''); + + // Slots + if (component.slots?.length > 0) { + lines.push('**Slots:**'); + lines.push(''); + for (const slot of component.slots) { + const slotName = slot.name || '(default)'; + lines.push(`- \`${slotName}\`: ${removeNewlines(slot.description) || 'No description.'}`); + } + lines.push(''); + } + + // Properties + const properties = + component.members?.filter(m => m.kind === 'field' && m.privacy !== 'private' && m.description) || []; + + if (properties.length > 0) { + lines.push('**Properties:**'); + lines.push(''); + for (const prop of properties) { + // Find corresponding attribute if any + const attr = component.attributes?.find(a => a.fieldName === prop.name); + const attrNote = attr && attr.name !== prop.name ? ` (attribute: \`${attr.name}\`)` : ''; + const typeStr = prop.type?.text ? `Type: \`${removeNewlines(prop.type.text)}\`` : ''; + const defaultStr = prop.default ? `Default: \`${prop.default}\`` : ''; + const meta = [typeStr, defaultStr].filter(Boolean).join(', '); + + lines.push( + `- \`${prop.name}\`${attrNote}: ${removeNewlines(prop.description) || 'No description.'}${meta ? ` (${meta})` : ''}`, + ); + } + lines.push(''); + } + + // Methods + const methods = component.members?.filter(m => m.kind === 'method' && m.privacy !== 'private' && m.description) || []; + + if (methods.length > 0) { + lines.push('**Methods:**'); + lines.push(''); + for (const method of methods) { + const params = method.parameters?.length + ? `(${method.parameters.map(p => `${p.name}: ${removeNewlines(p.type?.text) || 'unknown'}`).join(', ')})` + : '()'; + lines.push(`- \`${method.name}${params}\`: ${removeNewlines(method.description) || 'No description.'}`); + } + lines.push(''); + } + + // Events + const events = component.events?.filter(e => e.name) || []; + if (events.length > 0) { + lines.push('**Events:**'); + lines.push(''); + for (const event of events) { + lines.push(`- \`${event.name}\`: ${removeNewlines(event.description) || 'No description.'}`); + } + lines.push(''); + } + + // CSS Custom Properties + if (component.cssProperties?.length > 0) { + lines.push('**CSS Custom Properties:**'); + lines.push(''); + for (const prop of component.cssProperties) { + const defaultStr = prop.default ? ` (Default: \`${prop.default}\`)` : ''; + lines.push(`- \`${prop.name}\`: ${removeNewlines(prop.description) || 'No description.'}${defaultStr}`); + } + lines.push(''); + } + + // CSS Parts + if (component.cssParts?.length > 0) { + lines.push('**CSS Parts:**'); + lines.push(''); + for (const part of component.cssParts) { + lines.push(`- \`${part.name}\`: ${removeNewlines(part.description) || 'No description.'}`); + } + lines.push(''); + } + + // CSS States + if (component.cssStates?.length > 0) { + lines.push('**CSS States:**'); + lines.push(''); + for (const state of component.cssStates) { + lines.push(`- \`${state.name}\`: ${removeNewlines(state.description) || 'No description.'}`); + } + lines.push(''); + } + + return lines; +} + +/** + * Generates the complete llms.txt content. + */ +function generateLlmsTxt({ components, packageData, frontMatterCache, baseUrl }) { + const lines = []; + + // H1 Title (required by llmstxt.org spec) + lines.push('# Web Awesome'); + lines.push(''); + + // Blockquote summary + lines.push(`> ${packageData.description} Version ${packageData.version}.`); + lines.push(''); + + // Overview section + lines.push( + ` +Web Awesome provides a comprehensive set of customizable, accessible web components for building modern +web applications. All components use shadow DOM and are framework-agnostic, working with vanilla JavaScript +or any framework including React, Vue, Angular, and Svelte. + +Form controls are form-associated custom elements that work with native form validation and the +Constraint Validation API. + +Font Awesome is the default icon library, so \`\` values should reference Font Awesome +icon names. +`.trim(), + ); + lines.push(''); + + // + // Documentation + // + lines.push('## Documentation'); + lines.push(''); + lines.push(`For comprehensive documentation, visit ${baseUrl}/docs/`); + lines.push(''); + lines.push(`- [Getting Started](${baseUrl}/docs/getting-started): Installation and setup guide`); + lines.push(`- [Components Overview](${baseUrl}/docs/components): Complete component reference`); + lines.push(`- [Theming](${baseUrl}/docs/theming): Customization and design tokens`); + lines.push(`- [Form Controls](${baseUrl}/docs/form-controls): Form integration and validation`); + lines.push(''); + + // + // Components + // + lines.push('## Components'); + lines.push(''); + + const sortedComponentsList = [...components].sort((a, b) => a.tagName.localeCompare(b.tagName)); + + for (const component of sortedComponentsList) { + const frontMatter = frontMatterCache.get(component.tagName); + const description = removeNewlines(frontMatter?.description || component.summary || ''); + const componentSlug = component.tagName.replace(/^wa-/, ''); + const title = frontMatter?.title || componentSlug; + + lines.push( + `- [${title}](${baseUrl}/docs/components/${componentSlug}): ${description || 'No description available.'}`, + ); + } + lines.push(''); + + // + // Optional + // + lines.push('## Optional'); + lines.push(''); + lines.push( + `The following is a quick reference describing every component's API. For comprehensive documentation, refer to the component documentation using the URLs provided above.`, + ); + lines.push(''); + + // Sort components alphabetically by tag name for the API reference + const sortedComponents = [...components].sort((a, b) => a.tagName.localeCompare(b.tagName)); + + for (const component of sortedComponents) { + lines.push(...generateComponentApiSection(component, frontMatterCache, baseUrl)); + } + + return lines.join('\n').trim(); +} + +/** + * A CEM plugin that generates an llms.txt file following the llmstxt.org specification. + */ +export function llmsTxtPlugin(options = {}) { + const { + outdir = 'dist-cdn', + docsDir = path.resolve(__dirname, '../docs'), + baseUrl = 'https://webawesome.com', + } = options; + + return { + name: 'wa-llms-txt', + packageLinkPhase({ customElementsManifest }) { + const components = getAllComponents(customElementsManifest); + const packageData = customElementsManifest.package || {}; + const frontMatterCache = loadAllFrontMatter(components, docsDir); + + const llmsTxt = generateLlmsTxt({ + components, + packageData, + frontMatterCache, + baseUrl, + }); + + // Write to the output directory + const outputPath = path.join(outdir, 'llms.txt'); + fs.writeFileSync(outputPath, llmsTxt, 'utf-8'); + }, + }; +} + +export default llmsTxtPlugin;