diff --git a/.cursor/plans/generate-llms-spec.md b/.cursor/plans/generate-llms-spec.md new file mode 100644 index 00000000..ed172238 --- /dev/null +++ b/.cursor/plans/generate-llms-spec.md @@ -0,0 +1,178 @@ +# generate-llms.mjs -- Script Specification + +Stage 1 of the [llms.txt web presence plan](llms-txt_web_presence_c57a751b.plan.md). + +## Purpose + +A deterministic Node.js ESM script that reads `packages/interact/rules/*.md` and produces two output files: + +1. **`llms.txt`** -- a lightweight table-of-contents following the [llmstxt.org spec](https://llmstxt.org) +2. **`llms-full.txt`** -- all rule files concatenated into a single document + +Both files are generated (never hand-edited). The script runs at deploy time in CI and locally via `yarn generate:llms`. + +## File + +`scripts/generate-llms.mjs` -- plain ESM, no dependencies beyond `node:fs` and `node:path`. + +## Inputs + +- **Rules directory**: `packages/interact/rules/` (all `.md` files) +- **Package metadata**: `packages/interact/package.json` (reads `version` and `description`) + +## Outputs + +The script writes three files (all paths relative to repo root): + +| Output | Purpose | +| ---------------------------- | ------------------------------------------------------------------ | +| `llms.txt` | Deployed to site root; also the file that ships in the npm package | +| `llms-full.txt` | Deployed to site root only | +| `packages/interact/llms.txt` | Copy for npm package inclusion (identical to root `llms.txt`) | + +### llms.txt format + +Must conform to the llmstxt.org spec: exactly one H1, blockquote immediately after, body text, then H2 sections with link lists. + +``` +# @wix/interact + +> {description from package.json} + +- Install: `npm install @wix/interact @wix/motion-presets` +- Three entry points: vanilla JS (`@wix/interact`), React (`@wix/interact/react`), Web Components (`@wix/interact/web`) +- Five trigger types: hover, click, viewEnter, viewProgress, pointerMove +- Effects via named presets (`@wix/motion-presets`), keyframes, CSS transitions, or custom JS callbacks +- Configs are JSON-serializable -- designed for LLM generation + +## Docs + +- [Full Reference]({BASE_URL}/rules/full-lean.md): {extracted description} ({N} lines) +- [Integration Guide]({BASE_URL}/rules/integration.md): {extracted description} ({N} lines) + +## Optional + +- [{title}]({BASE_URL}/rules/{file}): {extracted description} ({N} lines) + ... one entry per trigger file, alphabetically ... +- [All rules in one file]({BASE_URL}/llms-full.txt): Complete concatenation ({total} lines) +``` + +Where: + +- `BASE_URL` = `https://wix.github.io/interact` +- `{extracted description}` = the text on the line immediately following the `# ...` heading in each file (the first non-empty line after the H1). Trim to first sentence if longer than 120 chars. +- `{N}` = line count of that file +- Body text (the bullet list between blockquote and `## Docs`) is **static/hardcoded** -- it describes the library's capabilities and does not change when rules files change. + +### llms-full.txt format + +``` +# @wix/interact v{version} -- AI Rules Reference +# {BASE_URL}/llms.txt +# {file_count} files, {total_lines} lines + +--- full-lean.md --- +{full content of full-lean.md} + +--- integration.md --- +{full content of integration.md} + +--- click.md --- +... +``` + +Header lines use `#` comment style (not markdown headings -- this is a concatenated document, not a spec-compliant llms.txt). Each file is separated by `--- {filename} ---` on its own line, followed by a blank line, then the file content verbatim. No trailing separator after the last file. + +## File Ordering + +Explicit priority list, then alphabetical fallback for unknown files: + +1. `full-lean.md` (always first -- comprehensive reference) +2. `integration.md` (setup and framework patterns) +3. All remaining `.md` files, sorted alphabetically by filename + +This ordering is optimized for truncation: an agent reading only the first ~1000 lines of `llms-full.txt` still gets the two most important files. + +New files added to the rules directory in the future are automatically discovered and appended alphabetically after the priority files. + +## Determinism + +The script must produce **byte-identical output** given the same input files and package.json version. This means: + +- No timestamps, dates, or random values in output +- File discovery uses sorted directory listing +- Line counts are computed, not hardcoded + +## Error Handling + +- If `packages/interact/rules/` does not exist or contains zero `.md` files: exit with code 1 and a clear error message. +- If `packages/interact/package.json` is missing or has no `version` field: exit with code 1. +- If a rules `.md` file has no `# ` heading on its first line: use the filename (without extension) as the title. Log a warning to stderr. + +## Test Spec + +Tests live in `scripts/generate-llms.spec.mjs` and run with the repo's vitest setup. + +### Strategy + +Test the script's **core logic as imported functions** -- not by spawning a child process. The script should export its key functions so tests can call them directly with controlled inputs (temporary directories with fixture files). The script's CLI entry point (the top-level code that reads real paths and writes real files) remains a thin wrapper around these functions and does not need its own test. + +### Exported functions + +The script should export these for testability: + +- `generateLlmsTxt(files, metadata)` -- returns the `llms.txt` string + - `files`: array of `{ name, content, lineCount }` objects, already in final order + - `metadata`: `{ version, description, baseUrl }` +- `generateLlmsFullTxt(files, metadata)` -- returns the `llms-full.txt` string + - Same signature +- `orderFiles(fileNames)` -- returns sorted array applying the priority + alphabetical rule +- `extractDescription(content)` -- returns the description line from a markdown file + +### Test cases + +**orderFiles**: + +- Current 7 files: returns `['full-lean.md', 'integration.md', 'click.md', 'hover.md', 'pointermove.md', 'viewenter.md', 'viewprogress.md']` +- With unknown file `zebra.md`: appended after all known trigger files +- With unknown files `aaa.md` and `zzz.md`: both appended alphabetically after known files +- Empty array: returns empty array +- Only unknown files: returns them sorted alphabetically + +**extractDescription**: + +- Standard file (`# Title\n\nDescription line here`): returns `"Description line here"` +- File with blank lines between heading and description: skips blanks, returns first non-empty line +- File with no content after heading: returns empty string +- File with no `# ` heading: returns empty string +- Long description (>120 chars): truncated to first sentence (first `.` followed by space or EOL) + +**generateLlmsTxt**: + +- With the current 7 files: output starts with `# @wix/interact`, has `> ` blockquote, has `## Docs` with 2 entries, has `## Optional` with 6 entries (5 triggers + llms-full.txt link) +- All URLs are absolute HTTPS +- Line counts in parentheses match input +- Body text (static bullets) is present between blockquote and `## Docs` +- Exactly one H1 in the entire output +- No trailing whitespace on any line + +**generateLlmsFullTxt**: + +- Header contains version and file count +- Each file preceded by `--- {filename} ---` separator +- Content of each file appears verbatim (byte-equal to input) +- Files appear in the correct order +- No separator after the last file's content +- Total line count in header matches actual line count of body + +**Determinism**: + +- Calling `generateLlmsTxt` twice with same input produces identical output +- Calling `generateLlmsFullTxt` twice with same input produces identical output + +### Not tested (avoid rabbit holes) + +- Filesystem I/O (reading real files, writing output) -- tested manually during the verify step +- CLI exit codes -- trivial wrapper, not worth mocking `process.exit` +- Network accessibility of generated URLs -- verified manually post-deploy +- Content correctness of the static body text -- it's a string literal, testing it would just duplicate it diff --git a/.cursor/plans/llms-txt_web_presence_c57a751b.plan.md b/.cursor/plans/llms-txt_web_presence_c57a751b.plan.md new file mode 100644 index 00000000..a3e98315 --- /dev/null +++ b/.cursor/plans/llms-txt_web_presence_c57a751b.plan.md @@ -0,0 +1,204 @@ +--- +name: llms-txt web presence +overview: Add llms.txt and llms-full.txt to the docs site for AI agent discoverability, with deterministic generation scripts that stay in sync as rules evolve. +todos: + - id: gen-script + content: Create `scripts/generate-llms.mjs` -- reads rules dir, produces both `llms.txt` and `llms-full.txt` deterministically + status: completed + - id: workflow + content: Update `.github/workflows/interactdocs.yml` -- run generation script and copy outputs to `_site/` + status: pending + - id: npm-ship + content: Add `llms.txt` to `packages/interact/package.json` files array, add `.gitignore` entries for generated files + status: pending + - id: canonical-url + content: Add canonical URL HTML comment to top of `full-lean.md` + status: pending + - id: root-script + content: Add `generate:llms` script to root `package.json` + status: pending + - id: verify + content: Run generation script locally and verify both outputs are correct + status: pending +isProject: false +--- + +# llms.txt + Web Presence (Revised) + +## Research Context + +Surveyed the llms.txt ecosystem as of May 2026 to inform this plan: + +- **Spec**: The [llmstxt.org](https://llmstxt.org) original spec (Jeremy Howard, Sep 2024) remains the de facto standard for OSS libraries. The extended v1.7.0 (ai-visibility.org.uk) adds business-oriented sections (`## Contact`, `identity.json`) that are not relevant here. We follow the original spec. +- **Comparable libraries with llms.txt**: + - [GSAP](https://gsap.com/llms.txt) -- pure link index (511 lines), flat H2 sections by API category, relative URLs to `/docs/v3/*.md` + - [PixiJS](https://pixijs.com/llms.txt) -- cleanest spec compliance: H1 + blockquote + H2 sections with `.md` links. Offers **three tiers**: `llms.txt` (nav index), `llms-medium.txt` (guides without API), `llms-full.txt` (complete). Also ships `pixijs-skills` npm package for agent install. + - [GSAP Vault](https://gsapvault.com/llms.txt) -- richer body text with inline descriptions, technique tags, framework integration snippets. Links to `/llms-full.txt` under `## For More Details`. + - [Popmotion](https://context7.com/popmotion/popmotion/llms.txt) -- hosted on Context7, full inline docs with code examples (not link-based). + - [Anime.js v4](https://github.com/juliangarnier/anime/issues/1105) -- community-contributed `.instructions.md`, not yet merged. + - [Motion.dev](https://motion.dev/docs/ai-kit) -- no llms.txt; uses MCP server + paid AI Kit ($399). +- **Spec key rules**: (1) exactly one H1; (2) blockquote immediately after H1; (3) body text before H2s is allowed; (4) `## Optional` is a reserved section name meaning "can be skipped for shorter context"; (5) link format is `- [Name](url): Description`; (6) all URLs should be absolute HTTPS; (7) no marketing hyperbole. +- **Registry**: [llms-txt-hub](https://github.com/thedaviddias/llms-txt-hub) (650+ entries) accepts submissions via web form at llmstxthub.com or PR with `.mdx` file in `packages/content/data/websites/`. + +**Design decisions informed by research**: + +- Two tiers (not three): our total corpus is ~2115 lines / ~65KB. PixiJS needs three tiers because their full docs are 29,000+ lines. For us, `full-lean.md` (700 lines) already serves as the "medium" read -- an agent can fetch just that one file. Adding a separate `llms-medium.txt` would duplicate it without value. +- `## Optional` section: per the spec, this signals "skip these for shorter context." Trigger-specific files go here since `full-lean.md` already covers all triggers at a summary level. An agent only needs the trigger files for deep dives. +- Absolute URLs throughout (not relative): GSAP uses relative paths, but those only resolve when fetched from their domain. Absolute URLs work everywhere -- in npm, in GitHub raw views, in agent tool output. +- Body text includes install command and key capabilities (like GSAP Vault), not just a bare blockquote (like GSAP). + +--- + +## Current State + +The rules directory has **7 files / 2115 lines**: + +- `full-lean.md` (700 lines) -- comprehensive reference: config structure, triggers, effects, sequences, conditions, CSS generation +- `integration.md` (334 lines) -- framework entry points (vanilla/React/Web Components), registerEffects, config schema +- `click.md` (189 lines), `hover.md` (191 lines), `pointermove.md` (279 lines), `viewenter.md` (226 lines), `viewprogress.md` (196 lines) -- trigger-specific deep dives + +The deployment workflow ([`.github/workflows/interactdocs.yml`](.github/workflows/interactdocs.yml)) copies rules to `_site/rules/` at line 90. The site serves them as raw markdown at `https://wix.github.io/interact/rules/*.md`. GitHub Pages serves `.txt` files as `text/plain` and `.md` files as `application/octet-stream` -- both are fine for agent consumption (no HTML wrapping). + +--- + +## Deliverables + +### 1. Generation script: `scripts/generate-llms.mjs` + +A single ESM script that produces **both** files deterministically from the rules directory. This is the core of the "continuous development" strategy -- when rules change, re-running the script updates both outputs. + +**Design:** + +- Reads `packages/interact/rules/` directory +- Produces `llms.txt` (table of contents) and `llms-full.txt` (concatenated content) +- File discovery is **dynamic** (reads directory listing), but ordering is explicit via a priority list with a fallback for unknown files (alphabetical). New files get included automatically but appended at the end. +- The script reads the package version from [`packages/interact/package.json`](packages/interact/package.json) and embeds it in both outputs. +- The script extracts the first heading line (`# ...`) from each file to generate link descriptions automatically (no hardcoded descriptions to drift). +- Plain ESM, no dependencies beyond `node:fs` and `node:path`. + +**File ordering** (optimized for truncation -- essentials first): + +1. `full-lean.md` -- the comprehensive reference, always first +2. `integration.md` -- setup and framework patterns +3. Trigger files alphabetically: `click.md`, `hover.md`, `pointermove.md`, `viewenter.md`, `viewprogress.md` +4. Any new `.md` files added later -- appended alphabetically + +**llms.txt output** follows the [llmstxt.org spec](https://llmstxt.org): + +```markdown +# @wix/interact + +> Declarative, configuration-driven interaction library -- binds animations to triggers via JSON config. Web-native, AI-ready, framework-agnostic. + +- Install: `npm install @wix/interact @wix/motion-presets` +- Three entry points: vanilla JS (`@wix/interact`), React (`@wix/interact/react`), Web Components (`@wix/interact/web`) +- Five trigger types: hover, click, viewEnter, viewProgress, pointerMove +- Effects via named presets (`@wix/motion-presets`), keyframes, CSS transitions, or custom JS callbacks +- Configs are JSON-serializable -- designed for LLM generation + +## Docs + +- [Full Reference](https://wix.github.io/interact/rules/full-lean.md): Complete rules -- config structure, all triggers, effects, sequences, conditions, CSS generation, element resolution (700 lines) +- [Integration Guide](https://wix.github.io/interact/rules/integration.md): Entry points, registerEffects, config schema, FOUC prevention, static API (334 lines) + +## Optional + +- [Click Trigger](https://wix.github.io/interact/rules/click.md): Click and keyboard-activate interactions (189 lines) +- [Hover Trigger](https://wix.github.io/interact/rules/hover.md): Hover and keyboard-focus interactions (191 lines) +- [PointerMove Trigger](https://wix.github.io/interact/rules/pointermove.md): Pointer-driven real-time animations with hitArea and centeredToTarget (279 lines) +- [ViewEnter Trigger](https://wix.github.io/interact/rules/viewenter.md): Viewport entrance animations via IntersectionObserver (226 lines) +- [ViewProgress Trigger](https://wix.github.io/interact/rules/viewprogress.md): Scroll-driven animations via ViewTimeline (196 lines) +- [All rules in one file](https://wix.github.io/interact/llms-full.txt): Complete concatenation for single-fetch consumption (~2115 lines) +``` + +Key structural decisions vs. previous plan: + +- **`## Docs` instead of `## Reference`**: aligns with PixiJS and the llmstxt.org example (FastHTML uses `## Docs`). +- **Trigger files under `## Optional`**: per the spec, this section signals "can be skipped for shorter context." An agent reading only `## Docs` gets `full-lean.md` + `integration.md` (1034 lines) -- sufficient for most tasks. Trigger files are supplementary deep dives. +- **`llms-full.txt` also under `## Optional`**: it's an alternative consumption path, not required. +- **Body text as a bullet list**: concise capabilities that help an agent decide relevance before following any links. Mirrors GSAP Vault's approach but without marketing language. + +**llms-full.txt output**: all files concatenated with `--- filename ---` separators, preceded by a header block: + +``` +# @wix/interact v2.2.2 -- AI Rules Reference +# https://wix.github.io/interact/llms.txt +# 7 files, ~2115 lines +``` + +### 2. Update deployment workflow + +Edit [`.github/workflows/interactdocs.yml`](.github/workflows/interactdocs.yml) -- add after the rules copy (line 90): + +```yaml +# Generate llms.txt files for AI agent discoverability +node scripts/generate-llms.mjs +cp llms.txt _site/llms.txt +cp llms-full.txt _site/llms-full.txt +``` + +This runs generation at deploy time so files are always in sync with the rules that ship. No need to commit generated files. + +Also update the directory structure comment (lines 67-71) to document the new paths: + +```yaml +# /llms.txt -> AI agent discovery index (llmstxt.org standard) +# /llms-full.txt -> All rules concatenated for single-fetch consumption +``` + +### 3. Ship llms.txt in npm package + +Edit [`packages/interact/package.json`](packages/interact/package.json) `"files"` array: + +```json +"files": ["dist", "rules", "docs", "llms.txt"] +``` + +The generation script should write `llms.txt` directly into `packages/interact/` (in addition to repo root) so it's available for both the website deploy and the npm publish. Add `packages/interact/llms.txt` to `.gitignore` since it's generated. + +`llms-full.txt` does NOT ship in the npm package -- agents with local filesystem access can read `rules/` directly. Keeps the package lean. + +### 4. Add canonical URL to full-lean.md + +Add a single-line HTML comment at the top of [`packages/interact/rules/full-lean.md`](packages/interact/rules/full-lean.md): + +```markdown + + +# @wix/interact -- Rules +``` + +Only `full-lean.md` gets this -- it's the entry-point file every agent reads first. Adding it to all 7 files would waste tokens in every agent context. + +### 5. Root package.json script + +Add to [`package.json`](package.json) scripts: + +```json +"generate:llms": "node scripts/generate-llms.mjs" +``` + +### 6. Submit to registries (manual, post-deploy) + +**llms-txt-hub** (primary): After deployment, submit via the web form at [llmstxthub.com](https://llmstxthub.com) (log in with GitHub). The submission requires: + +- Website URL: `https://wix.github.io/interact/` +- llms.txt URL: `https://wix.github.io/interact/llms.txt` +- llms-full.txt URL: `https://wix.github.io/interact/llms-full.txt` +- Category: Developer Tools + +Alternatively, open a PR on [thedaviddias/llms-txt-hub](https://github.com/thedaviddias/llms-txt-hub) adding an `.mdx` file at `packages/content/data/websites/wix-interact-llms-txt.mdx`. Do NOT edit `data/websites.json` (auto-generated). + +**Context7** (secondary): Context7 indexes library docs for LLMs (e.g., [Popmotion on Context7](https://context7.com/popmotion/popmotion/llms.txt)). Consider submitting `@wix/interact` after the llms.txt is live. This is lower priority since Context7 often auto-discovers npm packages. + +--- + +## Files Touched + +- **NEW**: `scripts/generate-llms.mjs` (~100 lines) +- **EDIT**: `.github/workflows/interactdocs.yml` (add generation + copy steps) +- **EDIT**: `packages/interact/package.json` (add `llms.txt` to files array) +- **EDIT**: `packages/interact/rules/full-lean.md` (add canonical URL comment) +- **EDIT**: `package.json` (add `generate:llms` script) +- **EDIT**: `.gitignore` (add generated file entries) +- **GENERATED** (not committed): `llms.txt`, `llms-full.txt`, `packages/interact/llms.txt` diff --git a/.github/workflows/preview-llms.yml b/.github/workflows/preview-llms.yml new file mode 100644 index 00000000..d86000ef --- /dev/null +++ b/.github/workflows/preview-llms.yml @@ -0,0 +1,47 @@ +name: Preview llms.txt + +on: + pull_request: + paths: + - 'packages/interact/rules/**' + - 'scripts/generate-llms.mjs' + - 'packages/interact/package.json' + +permissions: + contents: read + pull-requests: write + +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + preview: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 24 + + - name: Generate llms.txt files + run: node scripts/generate-llms.mjs + + - name: Upload generated files + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: llms-txt-preview + path: | + llms.txt + llms-full.txt + + - name: Post or update PR comment + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + REPO: ${{ github.repository }} + RUN_ID: ${{ github.run_id }} + run: ./scripts/post-llms-preview.sh diff --git a/scripts/generate-llms.mjs b/scripts/generate-llms.mjs new file mode 100644 index 00000000..dc9a15b5 --- /dev/null +++ b/scripts/generate-llms.mjs @@ -0,0 +1,206 @@ +import { readFileSync, writeFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const BASE_URL = 'https://wix.github.io/interact'; +const KNOWN_ORDER = [ + 'full-lean.md', + 'integration.md', + 'click.md', + 'hover.md', + 'pointermove.md', + 'viewenter.md', + 'viewprogress.md', +]; +const DOCS_LINK_TITLES = new Map([ + ['full-lean.md', 'Full Reference'], + ['integration.md', 'Integration Guide'], +]); + +const STATIC_BODY = [ + '- Install: `npm install @wix/interact @wix/motion-presets`', + '- Three entry points: vanilla JS (`@wix/interact`), React (`@wix/interact/react`), Web Components (`@wix/interact/web`)', + '- Five trigger types: hover, click, viewEnter, viewProgress, pointerMove', + '- Effects via named presets (`@wix/motion-presets`), keyframes, CSS transitions, or custom JS callbacks', + '- Configs are JSON-serializable -- designed for LLM generation', +].join('\n'); + +/** + * Sorts file names: priority files first (in PRIORITY order), + * then remaining files alphabetically. + */ +export function orderFiles(fileNames) { + const known = KNOWN_ORDER.filter((name) => fileNames.includes(name)); + const unknown = fileNames.filter((name) => !KNOWN_ORDER.includes(name)).sort(); + return [...known, ...unknown]; +} + +/** + * Extracts the first non-empty line after the H1 heading. + * If the line exceeds 120 chars, truncates to the first sentence + * (first `.` followed by whitespace or end-of-string). + */ +export function extractDescription(content) { + const lines = content.split('\n'); + let pastHeading = false; + + for (const line of lines) { + if (!pastHeading) { + if (line.startsWith('# ')) { + pastHeading = true; + } + continue; + } + + const trimmed = line.trim(); + if (trimmed === '') continue; + + if (trimmed.length <= 120) return trimmed; + + const match = trimmed.match(/^(.*?\.)(?:\s|$)/); + if (match) return match[1]; + + return trimmed; + } + + return ''; +} + +/** + * Generates the llms.txt table-of-contents string (llmstxt.org spec). + * @param {Array<{name: string, content: string, lineCount: number}>} files - ordered file list + * @param {{version: string, description: string, baseUrl: string}} metadata + */ +export function generateLlmsTxt(files, metadata) { + const { description, baseUrl } = metadata; + const lines = []; + + lines.push('# @wix/interact'); + lines.push(''); + lines.push(`> ${description}`); + lines.push(''); + lines.push(STATIC_BODY); + lines.push(''); + lines.push('## Docs'); + lines.push(''); + + const docsFiles = files.filter((f) => DOCS_LINK_TITLES.has(f.name)); + for (const file of docsFiles) { + const title = DOCS_LINK_TITLES.get(file.name); + const desc = extractDescription(file.content); + lines.push(`- [${title}](${baseUrl}/rules/${file.name}): ${desc} (${file.lineCount} lines)`); + } + + lines.push(''); + lines.push('## Optional'); + lines.push(''); + + const optionalFiles = files.filter((f) => !DOCS_LINK_TITLES.has(f.name)); + for (const file of optionalFiles) { + const headingMatch = file.content.match(/^# (.+)$/m); + const title = headingMatch ? headingMatch[1].trim() : file.name.replace(/\.md$/, ''); + const desc = extractDescription(file.content); + lines.push(`- [${title}](${baseUrl}/rules/${file.name}): ${desc} (${file.lineCount} lines)`); + } + + const totalLines = files.reduce((sum, f) => sum + f.lineCount, 0); + lines.push( + `- [All rules in one file](${baseUrl}/llms-full.txt): Complete concatenation (${totalLines} lines)`, + ); + lines.push(''); + + return lines.join('\n'); +} + +/** + * Generates the llms-full.txt concatenated document string. + * @param {Array<{name: string, content: string, lineCount: number}>} files - ordered file list + * @param {{version: string, description: string, baseUrl: string}} metadata + */ +export function generateLlmsFullTxt(files, metadata) { + const { version, baseUrl } = metadata; + const totalLines = files.reduce((sum, f) => sum + f.lineCount, 0); + + let output = `# @wix/interact v${version} -- AI Rules Reference\n`; + output += `# ${baseUrl}/llms.txt\n`; + output += `# ${files.length} files, ${totalLines} lines\n`; + + for (const file of files) { + output += `\n--- ${file.name} ---\n\n`; + output += file.content; + } + + return output; +} + +// --------------- CLI entry point --------------- + +const RULES_DIR = 'packages/interact/rules'; +const PKG_PATH = 'packages/interact/package.json'; + +function countLines(content) { + const parts = content.split('\n'); + return parts[parts.length - 1] === '' ? parts.length - 1 : parts.length; +} + +function main() { + let pkg; + try { + pkg = JSON.parse(readFileSync(PKG_PATH, 'utf-8')); + } catch { + console.error(`Error: cannot read ${PKG_PATH}`); + process.exit(1); + } + + if (!pkg.version) { + console.error(`Error: no "version" field in ${PKG_PATH}`); + process.exit(1); + } + + let fileNames; + try { + fileNames = readdirSync(RULES_DIR).filter((f) => f.endsWith('.md')); + } catch { + console.error(`Error: cannot read rules directory ${RULES_DIR}`); + process.exit(1); + } + + if (fileNames.length === 0) { + console.error(`Error: no .md files found in ${RULES_DIR}`); + process.exit(1); + } + + const ordered = orderFiles(fileNames); + + const files = ordered.map((name) => { + const content = readFileSync(join(RULES_DIR, name), 'utf-8'); + const lineCount = countLines(content); + + if (!content.startsWith('# ')) { + console.warn(`Warning: ${name} has no H1 heading on first line`); + } + + return { name, content, lineCount }; + }); + + const metadata = { + version: pkg.version, + description: pkg.description, + baseUrl: BASE_URL, + }; + + const llmsTxt = generateLlmsTxt(files, metadata); + const llmsFullTxt = generateLlmsFullTxt(files, metadata); + + writeFileSync('llms.txt', llmsTxt); + writeFileSync('llms-full.txt', llmsFullTxt); + writeFileSync(join('packages/interact', 'llms.txt'), llmsTxt); + + console.log(`Generated llms.txt (${countLines(llmsTxt)} lines)`); + console.log(`Generated llms-full.txt (${countLines(llmsFullTxt)} lines)`); + console.log('Copied llms.txt to packages/interact/llms.txt'); +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/generate-llms.spec.mjs b/scripts/generate-llms.spec.mjs new file mode 100644 index 00000000..788d2ec3 --- /dev/null +++ b/scripts/generate-llms.spec.mjs @@ -0,0 +1,304 @@ +import { describe, it, expect } from 'vitest'; +import { + orderFiles, + extractDescription, + generateLlmsTxt, + generateLlmsFullTxt, +} from './generate-llms.mjs'; + +const BASE_URL = 'https://wix.github.io/interact'; + +const METADATA = { + version: '1.0.0', + description: 'Test description for the library.', + baseUrl: BASE_URL, +}; + +function makeFile(name, heading, descriptionLine, extraLines = 3) { + const lines = [`# ${heading}`, '', descriptionLine]; + for (let i = 0; i < extraLines; i++) { + lines.push(`Line ${i + 1} of ${name}.`); + } + lines.push(''); + const content = lines.join('\n'); + const lineCount = lines.length - 1; // trailing empty string from final \n + return { name, content, lineCount }; +} + +const CURRENT_FILENAMES = [ + 'full-lean.md', + 'integration.md', + 'click.md', + 'hover.md', + 'pointermove.md', + 'viewenter.md', + 'viewprogress.md', +]; + +function makeCurrentFiles() { + return [ + makeFile('full-lean.md', '@wix/interact — Rules', 'Complete reference.', 5), + makeFile( + 'integration.md', + '@wix/interact Integration Rules', + 'Setup and framework patterns.', + 4, + ), + makeFile( + 'click.md', + 'Click Trigger Rules for @wix/interact', + 'Click-triggered interactions.', + 2, + ), + makeFile( + 'hover.md', + 'Hover Trigger Rules for @wix/interact', + 'Hover-triggered interactions.', + 2, + ), + makeFile( + 'pointermove.md', + 'PointerMove Trigger Rules for @wix/interact', + 'Pointer-driven real-time animations.', + 3, + ), + makeFile( + 'viewenter.md', + 'ViewEnter Trigger Rules for @wix/interact', + 'Viewport entrance animations.', + 3, + ), + makeFile( + 'viewprogress.md', + 'ViewProgress Trigger Rules for @wix/interact', + 'Scroll-driven animations.', + 3, + ), + ]; +} + +// ─── orderFiles ────────────────────────────────────────────── + +describe('orderFiles', () => { + it('orders the current 7 files correctly', () => { + const shuffled = [ + 'viewprogress.md', + 'click.md', + 'integration.md', + 'hover.md', + 'full-lean.md', + 'pointermove.md', + 'viewenter.md', + ]; + expect(orderFiles(shuffled)).toEqual([ + 'full-lean.md', + 'integration.md', + 'click.md', + 'hover.md', + 'pointermove.md', + 'viewenter.md', + 'viewprogress.md', + ]); + }); + + it('appends unknown file after all known trigger files', () => { + const input = [...CURRENT_FILENAMES, 'zebra.md']; + const result = orderFiles(input); + expect(result.indexOf('zebra.md')).toBe(result.length - 1); + }); + + it('appends multiple unknown files alphabetically after known files', () => { + const input = [...CURRENT_FILENAMES, 'zzz.md', 'aaa.md']; + const result = orderFiles(input); + const unknownStart = CURRENT_FILENAMES.length; + expect(result.slice(unknownStart)).toEqual(['aaa.md', 'zzz.md']); + }); + + it('returns empty array for empty input', () => { + expect(orderFiles([])).toEqual([]); + }); + + it('sorts only-unknown files alphabetically', () => { + expect(orderFiles(['gamma.md', 'alpha.md', 'beta.md'])).toEqual([ + 'alpha.md', + 'beta.md', + 'gamma.md', + ]); + }); +}); + +// ─── extractDescription ────────────────────────────────────── + +describe('extractDescription', () => { + it('extracts description from a standard file', () => { + const content = '# Title\n\nDescription line here\n'; + expect(extractDescription(content)).toBe('Description line here'); + }); + + it('skips blank lines between heading and description', () => { + const content = '# Title\n\n\n\nDescription after blanks\n'; + expect(extractDescription(content)).toBe('Description after blanks'); + }); + + it('returns empty string when no content after heading', () => { + expect(extractDescription('# Title\n')).toBe(''); + expect(extractDescription('# Title\n\n\n')).toBe(''); + }); + + it('returns empty string when no heading exists', () => { + expect(extractDescription('No heading here\nJust text\n')).toBe(''); + }); + + it('truncates long description to first sentence', () => { + const long = + '# Title\n\nFirst sentence here. ' + + 'Second sentence that pushes the total well beyond one hundred and twenty characters easily enough to trigger truncation.\n'; + expect(extractDescription(long)).toBe('First sentence here.'); + }); + + it('handles period inside backticks followed by sentence break', () => { + const content = + '# Title\n\n' + + 'These rules help generate pointer-driven interactions using `@wix/interact`. ' + + 'PointerMove triggers create real-time animations that respond to mouse movement over elements or the entire viewport.\n'; + expect(extractDescription(content)).toBe( + 'These rules help generate pointer-driven interactions using `@wix/interact`.', + ); + }); + + it('returns the full line when under 120 chars', () => { + const content = '# Title\n\nShort description.\n'; + expect(extractDescription(content)).toBe('Short description.'); + }); + + it('returns full single-sentence line even if over 120 chars', () => { + const sentence = + 'This is one extremely long sentence without any period-space breaks that goes on and on far past one hundred and twenty characters total'; + const content = `# Title\n\n${sentence}\n`; + expect(extractDescription(content)).toBe(sentence); + }); +}); + +// ─── generateLlmsTxt ──────────────────────────────────────── + +describe('generateLlmsTxt', () => { + const files = makeCurrentFiles(); + const output = generateLlmsTxt(files, METADATA); + const outputLines = output.split('\n'); + + it('starts with exactly one H1', () => { + const h1Lines = outputLines.filter((l) => l.startsWith('# ')); + expect(h1Lines).toHaveLength(1); + expect(outputLines[0]).toBe('# @wix/interact'); + }); + + it('has a blockquote with the package description', () => { + expect(output).toContain(`> ${METADATA.description}`); + }); + + it('includes static body text between blockquote and ## Docs', () => { + const blockquoteIdx = outputLines.indexOf(`> ${METADATA.description}`); + const docsIdx = outputLines.indexOf('## Docs'); + expect(blockquoteIdx).toBeGreaterThan(-1); + expect(docsIdx).toBeGreaterThan(blockquoteIdx); + + const between = outputLines.slice(blockquoteIdx + 1, docsIdx).join('\n'); + expect(between).toContain('npm install @wix/interact'); + expect(between).toContain('Five trigger types'); + }); + + it('has ## Docs with 2 entries', () => { + const docsIdx = outputLines.indexOf('## Docs'); + const optIdx = outputLines.indexOf('## Optional'); + const docsLinks = outputLines.slice(docsIdx, optIdx).filter((l) => l.startsWith('- [')); + expect(docsLinks).toHaveLength(2); + }); + + it('has ## Optional with 6 entries (5 triggers + llms-full.txt)', () => { + const optIdx = outputLines.indexOf('## Optional'); + const optLinks = outputLines.slice(optIdx).filter((l) => l.startsWith('- [')); + expect(optLinks).toHaveLength(6); + }); + + it('uses absolute HTTPS URLs for all links', () => { + const links = outputLines.filter((l) => l.startsWith('- [')); + for (const link of links) { + expect(link).toMatch(/\(https:\/\//); + } + }); + + it('includes correct line counts in parentheses', () => { + for (const file of files) { + expect(output).toContain(`(${file.lineCount} lines)`); + } + }); + + it('has no trailing whitespace on any line', () => { + for (const line of outputLines) { + expect(line).toBe(line.trimEnd()); + } + }); +}); + +// ─── generateLlmsFullTxt ──────────────────────────────────── + +describe('generateLlmsFullTxt', () => { + const files = makeCurrentFiles(); + const output = generateLlmsFullTxt(files, METADATA); + + it('header contains version and file count', () => { + expect(output).toContain(`v${METADATA.version}`); + expect(output).toContain(`${files.length} files`); + }); + + it('each file is preceded by a separator', () => { + for (const file of files) { + expect(output).toContain(`--- ${file.name} ---`); + } + }); + + it('includes each file content verbatim', () => { + for (const file of files) { + expect(output).toContain(file.content); + } + }); + + it('files appear in the correct order', () => { + let lastIdx = -1; + for (const file of files) { + const idx = output.indexOf(`--- ${file.name} ---`); + expect(idx).toBeGreaterThan(lastIdx); + lastIdx = idx; + } + }); + + it('has no separator after the last file content', () => { + const lastFile = files[files.length - 1]; + const lastSepIdx = output.lastIndexOf('---'); + const lastContentIdx = output.lastIndexOf(lastFile.content); + expect(lastContentIdx).toBeGreaterThan(lastSepIdx); + }); + + it('total line count in header matches sum of file line counts', () => { + const totalLines = files.reduce((sum, f) => sum + f.lineCount, 0); + expect(output).toContain(`${totalLines} lines`); + }); +}); + +// ─── determinism ──────────────────────────────────────────── + +describe('determinism', () => { + const files = makeCurrentFiles(); + + it('generateLlmsTxt produces identical output on repeated calls', () => { + const a = generateLlmsTxt(files, METADATA); + const b = generateLlmsTxt(files, METADATA); + expect(a).toBe(b); + }); + + it('generateLlmsFullTxt produces identical output on repeated calls', () => { + const a = generateLlmsFullTxt(files, METADATA); + const b = generateLlmsFullTxt(files, METADATA); + expect(a).toBe(b); + }); +}); diff --git a/scripts/post-llms-preview.sh b/scripts/post-llms-preview.sh new file mode 100755 index 00000000..e0d63740 --- /dev/null +++ b/scripts/post-llms-preview.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Posts or updates a PR comment with the generated llms.txt preview. +# Called by the preview-llms workflow after generate-llms.mjs has run. +# +# Required env vars (set by the workflow): +# GH_TOKEN - GitHub token for API access +# PR_NUMBER - Pull request number +# REPO - owner/repo string +# RUN_ID - Workflow run ID (for artifact link) + +: "${GH_TOKEN:?GH_TOKEN is required}" +: "${PR_NUMBER:?PR_NUMBER is required}" +: "${REPO:?REPO is required}" +: "${RUN_ID:?RUN_ID is required}" + +MARKER='' +COMMENT_FILE=$(mktemp) +trap 'rm -f "$COMMENT_FILE"' EXIT + +{ + echo "$MARKER" + echo '### Generated `llms.txt` preview' + echo '' + echo '
' + echo 'llms.txt' + echo '' + echo '```markdown' + cat llms.txt + echo '```' + echo '' + echo '
' + echo '' + echo '
' + echo 'llms-full.txt header' + echo '' + echo '```' + head -3 llms-full.txt + echo '```' + echo '' + echo '
' + echo '' + echo "> [Download full files](https://github.com/${REPO}/actions/runs/${RUN_ID}) from workflow artifacts, or run \`node scripts/generate-llms.mjs\` locally." +} > "$COMMENT_FILE" + +COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" \ + --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ + | head -1) + +if [ -n "$COMMENT_ID" ]; then + jq -Rs '{body: .}' "$COMMENT_FILE" | \ + gh api "repos/${REPO}/issues/comments/${COMMENT_ID}" -X PATCH --input - +else + gh pr comment "$PR_NUMBER" --body-file "$COMMENT_FILE" +fi