From e608c4de6c8489ca707dc1823e8e354ce66dfec3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 28 May 2026 10:12:24 +0000 Subject: [PATCH 01/14] chore(scripts): add extract-public-api.js for cross-repo contract checking Used by the prep-flip T2.5 tier to extract the HyperFormula public API surface at a given git ref and compare between develop and PR HEAD to detect breaking API changes (constructor arg additions, removed exports, lint-scope drift). --- scripts/extract-public-api.js | 310 ++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 scripts/extract-public-api.js diff --git a/scripts/extract-public-api.js b/scripts/extract-public-api.js new file mode 100644 index 000000000..9187d8853 --- /dev/null +++ b/scripts/extract-public-api.js @@ -0,0 +1,310 @@ +#!/usr/bin/env node +/** + * Extracts the HyperFormula public API surface at a given git ref (or from + * the current working tree when --ref is omitted). + * + * Used by the prep-flip T2.5 tier (cross-repo contract check) to detect + * breaking changes between develop and a PR HEAD. + * + * Usage: + * node extract-public-api.js [--ref ] + * + * Output (stdout): { exports: ApiExport[], lint_scope: string[] } + * + * @typedef {{ file: string, name: string, kind: string, + * required_params: string[], optional_params: string[] }} ApiExport + * @typedef {{ exports: ApiExport[], lint_scope: string[] }} ApiSurface + */ +'use strict'; + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +// --------------------------------------------------------------------------- +// CLI args +// --------------------------------------------------------------------------- + +const args = process.argv.slice(2); +const refIdx = args.indexOf('--ref'); +/** @type {string|null} */ +const ref = refIdx !== -1 ? (args[refIdx + 1] ?? null) : null; + +// --------------------------------------------------------------------------- +// Source-reading helpers — git show at ref OR filesystem fallback +// --------------------------------------------------------------------------- + +/** + * Read a repo-relative file either at the given git ref or from the working + * tree. Returns null when the file cannot be found at either location. + * + * @param {string} repoRelPath e.g. "src/index.ts" + * @returns {string|null} + */ +function readFile(repoRelPath) { + if (ref) { + try { + return execSync(`git show "${ref}:${repoRelPath}"`, { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }); + } catch { + // Fall through to working-tree read below. + } + } + // Working-tree fallback: resolve relative to the repo root. + try { + const repoRoot = execSync('git rev-parse --show-toplevel', { + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + const absPath = path.join(repoRoot, repoRelPath); + return fs.readFileSync(absPath, 'utf8'); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Parameter-list parsing helpers +// --------------------------------------------------------------------------- + +/** + * Split a parameter-list string on top-level commas, respecting angle-bracket + * and parenthesis nesting for generic types like `Map`. + * + * @param {string} str + * @returns {string[]} + */ +function splitParams(str) { + /** @type {string[]} */ + const parts = []; + let depth = 0; + let current = ''; + for (const ch of str) { + if ('<([{'.includes(ch)) depth += 1; + else if ('>)]}' .includes(ch)) depth -= 1; + else if (ch === ',' && depth === 0) { + parts.push(current); + current = ''; + continue; + } + current += ch; + } + if (current.trim()) parts.push(current); + return parts; +} + +/** + * Parse a raw parameter string into required/optional name lists. + * + * @param {string|null|undefined} paramStr + * @returns {{ required: string[], optional: string[] }} + */ +function parseParams(paramStr) { + if (!paramStr || !paramStr.trim()) return { required: [], optional: [] }; + const required = /** @type {string[]} */ ([]); + const optional = /** @type {string[]} */ ([]); + for (const raw of splitParams(paramStr)) { + const trimmed = raw.trim(); + if (!trimmed) continue; + // Strip TypeScript access modifiers (constructor injection pattern). + const clean = trimmed.replace(/^(private|public|protected|readonly|\s)+/, ''); + // Extract parameter name (before `:`, `?`, `=`, or rest `...`). + const nameMatch = clean.match(/^\.{3}?(\w+)/); + const paramName = nameMatch + ? nameMatch[1] + : (clean.split(/[?:=\s]/)[0] ?? '').trim(); + if (!paramName) continue; + // Optional when the name fragment contains `?` or a default `=` precedes `:`. + const beforeColon = clean.split(':')[0] ?? ''; + const isOptional = beforeColon.includes('?') || beforeColon.includes('='); + if (isOptional) { + optional.push(paramName.replace('?', '')); + } else { + required.push(paramName); + } + } + return { required, optional }; +} + +// --------------------------------------------------------------------------- +// src/index.ts — named export extraction +// --------------------------------------------------------------------------- + +/** + * Extract all public exports from src/index.ts: + * - `export { X, Y }` or `export { X, Y } from '...'` → kind 'unknown' + * - `export class X ...` → kind 'class' + * - `export function X(...)` → kind 'function' + * - `export type/interface/enum X ...` → kind 'type' + * + * @param {string} src + * @returns {ApiExport[]} + */ +function extractIndexExports(src) { + if (!src) return []; + /** @type {ApiExport[]} */ + const indexExports = []; + + // Named export blocks (with or without `from`), multiline-safe via [\s\S]. + // Matches: export { A, B, C } [from '...'] + const namedExportRe = /export\s*\{([\s\S]*?)\}(?:\s*from\s*['"][^'"]+['"])?/g; + let m; + while ((m = namedExportRe.exec(src)) !== null) { + const names = m[1] + .split(',') + .map(n => n.trim().replace(/\/\/[^\n]*/g, '').trim()) // strip inline comments + .map(n => n.split(/\s+as\s+/).pop()?.trim()) + .filter(n => n && /^\w/.test(n)); + for (const name of names) { + if (!indexExports.some(e => e.name === name)) { + indexExports.push({ + file: 'src/index.ts', + name: /** @type {string} */ (name), + kind: 'unknown', + required_params: [], + optional_params: [], + }); + } + } + } + + // Direct exports: export class X / export function X(...) / export type X + const directClassRe = /export\s+(?:abstract\s+)?class\s+(\w+)/g; + while ((m = directClassRe.exec(src)) !== null) { + const name = m[1]; + if (!indexExports.some(e => e.name === name)) { + indexExports.push({ file: 'src/index.ts', name, kind: 'class', required_params: [], optional_params: [] }); + } + } + + const directFnRe = /export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g; + while ((m = directFnRe.exec(src)) !== null) { + const name = m[1]; + const params = parseParams(m[2]); + if (!indexExports.some(e => e.name === name)) { + indexExports.push({ + file: 'src/index.ts', + name, + kind: 'function', + required_params: params.required, + optional_params: params.optional, + }); + } + } + + const directTypeRe = /export\s+(?:type|interface|enum)\s+(\w+)/g; + while ((m = directTypeRe.exec(src)) !== null) { + const name = m[1]; + if (!indexExports.some(e => e.name === name)) { + indexExports.push({ file: 'src/index.ts', name, kind: 'type', required_params: [], optional_params: [] }); + } + } + + return indexExports; +} + +// --------------------------------------------------------------------------- +// src/HyperFormula.ts — public method + constructor extraction +// --------------------------------------------------------------------------- + +/** + * Extract the HyperFormula constructor signature and all public instance/static + * methods from src/HyperFormula.ts. + * + * @param {string|null} src + * @returns {ApiExport[]} + */ +function extractHyperFormulaExports(src) { + if (!src) return []; + /** @type {ApiExport[]} */ + const exports = []; + + // Constructor + const ctorMatch = src.match(/\bconstructor\s*\(([^)]*)\)/); + if (ctorMatch) { + const params = parseParams(ctorMatch[1]); + exports.push({ + file: 'src/HyperFormula.ts', + name: 'HyperFormula.constructor', + kind: 'constructor', + required_params: params.required, + optional_params: params.optional, + }); + } + + // Public (static) (async) (get|set) methodName(params) + const methodRe = + /(?:^|\n)[ \t]+public\s+(static\s+)?(async\s+)?(?:(?:get|set)\s+)?(\w[\w]*)\s*\(([^)]*)\)/g; + let m; + while ((m = methodRe.exec(src)) !== null) { + const name = m[3]; + if (name === 'constructor') continue; + if (name.startsWith('_')) continue; // private-by-convention + + const isStatic = !!m[1]; + const params = parseParams(m[4]); + exports.push({ + file: 'src/HyperFormula.ts', + name: isStatic ? `HyperFormula.${name}` : `HyperFormula#${name}`, + kind: isStatic ? 'static-method' : 'method', + required_params: params.required, + optional_params: params.optional, + }); + } + + return exports; +} + +// --------------------------------------------------------------------------- +// tsconfig.json — lint_scope extraction +// --------------------------------------------------------------------------- + +/** + * Derive the lint scope from tsconfig.json `include` patterns. + * Returns an array of top-level directory prefixes, e.g. `['src', 'test']`. + * + * @returns {string[]} + */ +function extractLintScope() { + const tsconfigSrc = readFile('tsconfig.json'); + if (!tsconfigSrc) return ['src']; + try { + // tsconfig uses JSON5-ish syntax — strip single-line comments before parsing. + const stripped = tsconfigSrc.replace(/\/\/[^\n]*/g, ''); + const tsconfig = JSON.parse(stripped); + const include = Array.isArray(tsconfig.include) ? tsconfig.include : ['src']; + return include + .map(p => + String(p) + .replace(/\/\*\*.*/, '') + .replace(/\/\*.*/, '') + .replace(/\/$/, ''), + ) + .filter(p => p.length > 0); + } catch { + return ['src']; + } +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const indexSrc = readFile('src/index.ts'); +const hfSrc = readFile('src/HyperFormula.ts'); + +/** @type {ApiExport[]} */ +const apiExports = [ + ...extractIndexExports(indexSrc ?? ''), + ...extractHyperFormulaExports(hfSrc), +]; + +/** @type {string[]} */ +const lint_scope = extractLintScope(); + +/** @type {ApiSurface} */ +const result = { exports: apiExports, lint_scope }; + +process.stdout.write(JSON.stringify(result) + '\n'); From 34c32d52b88266aee8b432344c634464dcc7e8d3 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Thu, 28 May 2026 10:45:12 +0000 Subject: [PATCH 02/14] fix(scripts): make extract-public-api.js lint-clean under project ESLint config Add scripts/*.js override to .eslintrc.js enabling node env and disabling TypeScript-specific rules that do not apply to CJS scripts. Auto-fix semicolons (project enforces no-semicolons style), rename lint_scope variable to lintScope (naming-convention), and add missing JSDoc param/returns descriptions. --- .eslintrc.js | 18 +++- scripts/extract-public-api.js | 182 +++++++++++++++++----------------- 2 files changed, 108 insertions(+), 92 deletions(-) diff --git a/.eslintrc.js b/.eslintrc.js index 7d6108d36..fa0f60c8f 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -148,6 +148,22 @@ module.exports = { rules: { '@typescript-eslint/no-non-null-assertion': 'off', } - } + }, + { + files: ['scripts/*.js'], + env: { + node: true, + }, + rules: { + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + 'no-undef': 'off', + }, + }, ], } diff --git a/scripts/extract-public-api.js b/scripts/extract-public-api.js index 9187d8853..5ffd6bcc9 100644 --- a/scripts/extract-public-api.js +++ b/scripts/extract-public-api.js @@ -15,20 +15,20 @@ * required_params: string[], optional_params: string[] }} ApiExport * @typedef {{ exports: ApiExport[], lint_scope: string[] }} ApiSurface */ -'use strict'; +'use strict' -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); +const { execSync } = require('child_process') +const fs = require('fs') +const path = require('path') // --------------------------------------------------------------------------- // CLI args // --------------------------------------------------------------------------- -const args = process.argv.slice(2); -const refIdx = args.indexOf('--ref'); +const args = process.argv.slice(2) +const refIdx = args.indexOf('--ref') /** @type {string|null} */ -const ref = refIdx !== -1 ? (args[refIdx + 1] ?? null) : null; +const ref = refIdx !== -1 ? (args[refIdx + 1] ?? null) : null // --------------------------------------------------------------------------- // Source-reading helpers — git show at ref OR filesystem fallback @@ -39,7 +39,7 @@ const ref = refIdx !== -1 ? (args[refIdx + 1] ?? null) : null; * tree. Returns null when the file cannot be found at either location. * * @param {string} repoRelPath e.g. "src/index.ts" - * @returns {string|null} + * @returns {string|null} File contents, or null when the file is not found. */ function readFile(repoRelPath) { if (ref) { @@ -47,7 +47,7 @@ function readFile(repoRelPath) { return execSync(`git show "${ref}:${repoRelPath}"`, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], - }); + }) } catch { // Fall through to working-tree read below. } @@ -57,11 +57,11 @@ function readFile(repoRelPath) { const repoRoot = execSync('git rev-parse --show-toplevel', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], - }).trim(); - const absPath = path.join(repoRoot, repoRelPath); - return fs.readFileSync(absPath, 'utf8'); + }).trim() + const absPath = path.join(repoRoot, repoRelPath) + return fs.readFileSync(absPath, 'utf8') } catch { - return null; + return null } } @@ -73,59 +73,59 @@ function readFile(repoRelPath) { * Split a parameter-list string on top-level commas, respecting angle-bracket * and parenthesis nesting for generic types like `Map`. * - * @param {string} str - * @returns {string[]} + * @param {string} str Raw parameter-list text. + * @returns {string[]} Individual parameter strings (whitespace preserved). */ function splitParams(str) { /** @type {string[]} */ - const parts = []; - let depth = 0; - let current = ''; + const parts = [] + let depth = 0 + let current = '' for (const ch of str) { - if ('<([{'.includes(ch)) depth += 1; - else if ('>)]}' .includes(ch)) depth -= 1; + if ('<([{'.includes(ch)) depth += 1 + else if ('>)]}' .includes(ch)) depth -= 1 else if (ch === ',' && depth === 0) { - parts.push(current); - current = ''; - continue; + parts.push(current) + current = '' + continue } - current += ch; + current += ch } - if (current.trim()) parts.push(current); - return parts; + if (current.trim()) parts.push(current) + return parts } /** * Parse a raw parameter string into required/optional name lists. * - * @param {string|null|undefined} paramStr - * @returns {{ required: string[], optional: string[] }} + * @param {string|null|undefined} paramStr Raw parameter-list string, or null/undefined for empty lists. + * @returns {{ required: string[], optional: string[] }} Parsed required and optional parameter name lists. */ function parseParams(paramStr) { - if (!paramStr || !paramStr.trim()) return { required: [], optional: [] }; - const required = /** @type {string[]} */ ([]); - const optional = /** @type {string[]} */ ([]); + if (!paramStr || !paramStr.trim()) return { required: [], optional: [] } + const required = /** @type {string[]} */ ([]) + const optional = /** @type {string[]} */ ([]) for (const raw of splitParams(paramStr)) { - const trimmed = raw.trim(); - if (!trimmed) continue; + const trimmed = raw.trim() + if (!trimmed) continue // Strip TypeScript access modifiers (constructor injection pattern). - const clean = trimmed.replace(/^(private|public|protected|readonly|\s)+/, ''); + const clean = trimmed.replace(/^(private|public|protected|readonly|\s)+/, '') // Extract parameter name (before `:`, `?`, `=`, or rest `...`). - const nameMatch = clean.match(/^\.{3}?(\w+)/); + const nameMatch = /^\.{3}?(\w+)/.exec(clean) const paramName = nameMatch ? nameMatch[1] - : (clean.split(/[?:=\s]/)[0] ?? '').trim(); - if (!paramName) continue; + : (clean.split(/[?:=\s]/)[0] ?? '').trim() + if (!paramName) continue // Optional when the name fragment contains `?` or a default `=` precedes `:`. - const beforeColon = clean.split(':')[0] ?? ''; - const isOptional = beforeColon.includes('?') || beforeColon.includes('='); + const beforeColon = clean.split(':')[0] ?? '' + const isOptional = beforeColon.includes('?') || beforeColon.includes('=') if (isOptional) { - optional.push(paramName.replace('?', '')); + optional.push(paramName.replace('?', '')) } else { - required.push(paramName); + required.push(paramName) } } - return { required, optional }; + return { required, optional } } // --------------------------------------------------------------------------- @@ -139,24 +139,24 @@ function parseParams(paramStr) { * - `export function X(...)` → kind 'function' * - `export type/interface/enum X ...` → kind 'type' * - * @param {string} src - * @returns {ApiExport[]} + * @param {string} src Source text of src/index.ts. + * @returns {ApiExport[]} All named exports found in the file. */ function extractIndexExports(src) { - if (!src) return []; + if (!src) return [] /** @type {ApiExport[]} */ - const indexExports = []; + const indexExports = [] // Named export blocks (with or without `from`), multiline-safe via [\s\S]. // Matches: export { A, B, C } [from '...'] - const namedExportRe = /export\s*\{([\s\S]*?)\}(?:\s*from\s*['"][^'"]+['"])?/g; - let m; + const namedExportRe = /export\s*\{([\s\S]*?)\}(?:\s*from\s*['"][^'"]+['"])?/g + let m while ((m = namedExportRe.exec(src)) !== null) { const names = m[1] .split(',') .map(n => n.trim().replace(/\/\/[^\n]*/g, '').trim()) // strip inline comments .map(n => n.split(/\s+as\s+/).pop()?.trim()) - .filter(n => n && /^\w/.test(n)); + .filter(n => n && /^\w/.test(n)) for (const name of names) { if (!indexExports.some(e => e.name === name)) { indexExports.push({ @@ -165,24 +165,24 @@ function extractIndexExports(src) { kind: 'unknown', required_params: [], optional_params: [], - }); + }) } } } // Direct exports: export class X / export function X(...) / export type X - const directClassRe = /export\s+(?:abstract\s+)?class\s+(\w+)/g; + const directClassRe = /export\s+(?:abstract\s+)?class\s+(\w+)/g while ((m = directClassRe.exec(src)) !== null) { - const name = m[1]; + const name = m[1] if (!indexExports.some(e => e.name === name)) { - indexExports.push({ file: 'src/index.ts', name, kind: 'class', required_params: [], optional_params: [] }); + indexExports.push({ file: 'src/index.ts', name, kind: 'class', required_params: [], optional_params: [] }) } } - const directFnRe = /export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g; + const directFnRe = /export\s+(?:async\s+)?function\s+(\w+)\s*\(([^)]*)\)/g while ((m = directFnRe.exec(src)) !== null) { - const name = m[1]; - const params = parseParams(m[2]); + const name = m[1] + const params = parseParams(m[2]) if (!indexExports.some(e => e.name === name)) { indexExports.push({ file: 'src/index.ts', @@ -190,19 +190,19 @@ function extractIndexExports(src) { kind: 'function', required_params: params.required, optional_params: params.optional, - }); + }) } } - const directTypeRe = /export\s+(?:type|interface|enum)\s+(\w+)/g; + const directTypeRe = /export\s+(?:type|interface|enum)\s+(\w+)/g while ((m = directTypeRe.exec(src)) !== null) { - const name = m[1]; + const name = m[1] if (!indexExports.some(e => e.name === name)) { - indexExports.push({ file: 'src/index.ts', name, kind: 'type', required_params: [], optional_params: [] }); + indexExports.push({ file: 'src/index.ts', name, kind: 'type', required_params: [], optional_params: [] }) } } - return indexExports; + return indexExports } // --------------------------------------------------------------------------- @@ -213,48 +213,48 @@ function extractIndexExports(src) { * Extract the HyperFormula constructor signature and all public instance/static * methods from src/HyperFormula.ts. * - * @param {string|null} src - * @returns {ApiExport[]} + * @param {string|null} src Source text of src/HyperFormula.ts, or null when unavailable. + * @returns {ApiExport[]} Constructor entry plus all public method entries. */ function extractHyperFormulaExports(src) { - if (!src) return []; + if (!src) return [] /** @type {ApiExport[]} */ - const exports = []; + const exports = [] // Constructor - const ctorMatch = src.match(/\bconstructor\s*\(([^)]*)\)/); + const ctorMatch = /\bconstructor\s*\(([^)]*)\)/.exec(src) if (ctorMatch) { - const params = parseParams(ctorMatch[1]); + const params = parseParams(ctorMatch[1]) exports.push({ file: 'src/HyperFormula.ts', name: 'HyperFormula.constructor', kind: 'constructor', required_params: params.required, optional_params: params.optional, - }); + }) } // Public (static) (async) (get|set) methodName(params) const methodRe = - /(?:^|\n)[ \t]+public\s+(static\s+)?(async\s+)?(?:(?:get|set)\s+)?(\w[\w]*)\s*\(([^)]*)\)/g; - let m; + /(?:^|\n)[ \t]+public\s+(static\s+)?(async\s+)?(?:(?:get|set)\s+)?(\w[\w]*)\s*\(([^)]*)\)/g + let m while ((m = methodRe.exec(src)) !== null) { - const name = m[3]; - if (name === 'constructor') continue; - if (name.startsWith('_')) continue; // private-by-convention + const name = m[3] + if (name === 'constructor') continue + if (name.startsWith('_')) continue // private-by-convention - const isStatic = !!m[1]; - const params = parseParams(m[4]); + const isStatic = !!m[1] + const params = parseParams(m[4]) exports.push({ file: 'src/HyperFormula.ts', name: isStatic ? `HyperFormula.${name}` : `HyperFormula#${name}`, kind: isStatic ? 'static-method' : 'method', required_params: params.required, optional_params: params.optional, - }); + }) } - return exports; + return exports } // --------------------------------------------------------------------------- @@ -265,16 +265,16 @@ function extractHyperFormulaExports(src) { * Derive the lint scope from tsconfig.json `include` patterns. * Returns an array of top-level directory prefixes, e.g. `['src', 'test']`. * - * @returns {string[]} + * @returns {string[]} Top-level directory prefixes included by the TypeScript project. */ function extractLintScope() { - const tsconfigSrc = readFile('tsconfig.json'); - if (!tsconfigSrc) return ['src']; + const tsconfigSrc = readFile('tsconfig.json') + if (!tsconfigSrc) return ['src'] try { // tsconfig uses JSON5-ish syntax — strip single-line comments before parsing. - const stripped = tsconfigSrc.replace(/\/\/[^\n]*/g, ''); - const tsconfig = JSON.parse(stripped); - const include = Array.isArray(tsconfig.include) ? tsconfig.include : ['src']; + const stripped = tsconfigSrc.replace(/\/\/[^\n]*/g, '') + const tsconfig = JSON.parse(stripped) + const include = Array.isArray(tsconfig.include) ? tsconfig.include : ['src'] return include .map(p => String(p) @@ -282,9 +282,9 @@ function extractLintScope() { .replace(/\/\*.*/, '') .replace(/\/$/, ''), ) - .filter(p => p.length > 0); + .filter(p => p.length > 0) } catch { - return ['src']; + return ['src'] } } @@ -292,19 +292,19 @@ function extractLintScope() { // Main // --------------------------------------------------------------------------- -const indexSrc = readFile('src/index.ts'); -const hfSrc = readFile('src/HyperFormula.ts'); +const indexSrc = readFile('src/index.ts') +const hfSrc = readFile('src/HyperFormula.ts') /** @type {ApiExport[]} */ const apiExports = [ ...extractIndexExports(indexSrc ?? ''), ...extractHyperFormulaExports(hfSrc), -]; +] /** @type {string[]} */ -const lint_scope = extractLintScope(); +const lintScope = extractLintScope() /** @type {ApiSurface} */ -const result = { exports: apiExports, lint_scope }; +const result = { exports: apiExports, lint_scope: lintScope } -process.stdout.write(JSON.stringify(result) + '\n'); +process.stdout.write(JSON.stringify(result) + '\n') From 760ab4adcf81657eb7247c1e1eda89621e8e0a8e Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 31 May 2026 22:39:44 +0000 Subject: [PATCH 03/14] docs(specs): add HF-154 agent-friendly docs design spec Five-component design: VuePress md-companions plugin, Copy Markdown button, static setup-coding-agent page, interactive wizard, and llms.txt/robots.txt updates. Co-Authored-By: Claude Sonnet 4.6 --- ...-05-31-hf154-agent-friendly-docs-design.md | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-hf154-agent-friendly-docs-design.md diff --git a/docs/superpowers/specs/2026-05-31-hf154-agent-friendly-docs-design.md b/docs/superpowers/specs/2026-05-31-hf154-agent-friendly-docs-design.md new file mode 100644 index 000000000..7f41f1084 --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-hf154-agent-friendly-docs-design.md @@ -0,0 +1,175 @@ +# HF-154: Agent-Friendly Docs — Design Spec + +**Date:** 2026-05-31 +**Status:** Approved (meeting 2026-05-28, Marcin + Kuba) +**Branch target:** develop + +## Context + +Joseph (GreenFlux) already shipped in `hyperformula-website` repo: robots.txt, sitemap, llms.txt, public HF skill, Git MCP. He also has DRAFT PR #1686 migrating VuePress → Astro Starlight with `starlight-page-actions` (auto `.md` companions + Copy Markdown button). We stay on VuePress for this release and implement equivalent functionality, borrowing Joseph's UX patterns where useful. + +## Scope + +Five independent components, all targeted at VuePress 1.x under `docs/`. + +--- + +## C1: `vuepress-plugin-md-companions` + +**What it does:** Post-build VuePress plugin. For every guide page source file, strips VuePress-specific syntax and emits a clean `.md` companion file into `dist/` alongside the `.html`. Also emits `dist/llms-full.txt`. + +**Strips from source markdown:** +- Custom containers (`:::tip`, `:::warning`, `:::danger`) → strip fence, keep body text +- Inline `\nText after'), + 'Text before\nText after'); + +// 5. standalone Vue component removed +check('vue component removed', + stripVuePressSyntax('Intro\n\nOutro'), + 'Intro\nOutro'); + +// 6. [[toc]] removed +check('toc removed', + stripVuePressSyntax('# Title\n[[toc]]\nBody'), + '# Title\nBody'); + +// 7. headings, code, links, tables preserved +check('content preserved', + stripVuePressSyntax('# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'), + '# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'); + +console.log(`PASS md-companions/strip (${passed} assertions)`); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `node docs/.vuepress/plugins/md-companions/strip.test.js` +Expected: FAIL — `Cannot find module './strip'` + +- [ ] **Step 3: Implement `strip.js`** + +Create `docs/.vuepress/plugins/md-companions/strip.js`: + +```js +/** + * Strips VuePress-specific markdown syntax, producing clean markdown + * suitable for LLM consumption. Fence-aware: never edits inside code blocks. + * @param {string} src raw markdown (frontmatter already removed) + * @returns {string} cleaned markdown + */ +function stripVuePressSyntax(src) { + const lines = src.split('\n'); + const out = []; + let inFence = false; + let fenceMarker = ''; + let inScript = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + + // Track fenced code blocks (``` or ~~~). Inside a fence, copy verbatim. + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + if (fenceMatch && !inScript) { + if (!inFence) { + inFence = true; + fenceMarker = fenceMatch[1][0]; + } else if (trimmed.startsWith(fenceMarker)) { + inFence = false; + } + out.push(line); + continue; + } + if (inFence) { + out.push(line); + continue; + } + + // Remove + + +``` + +- [ ] **Step 2: Verify it parses (lint)** + +Run: `npx eslint docs/.vuepress/components/CopyMarkdownButton.vue --no-eslintrc --parser vue-eslint-parser 2>/dev/null || echo "manual review: confirm template/script/style blocks well-formed"` +Expected: no parse error (or fall back to manual confirmation — the project's ESLint targets `.js,.ts`, not `.vue`). + +- [ ] **Step 3: Commit** + +```bash +git add docs/.vuepress/components/CopyMarkdownButton.vue +git commit -m "feat(docs): add Copy Markdown button global component" +``` + +--- + +## Task 3: C3 — `setup-coding-agent.md` static page + +**Files:** +- Create: `docs/guide/setup-coding-agent.md` + +**Install-command source of truth:** `handsontable/handsontable-skills` README. Verified real commands: +- Claude Code marketplace: `/plugin marketplace add handsontable/handsontable-skills` then `/plugin install handsontable-skills@handsontable-skills` +- Manual: `git clone https://github.com/handsontable/handsontable-skills.git` then `cp -r handsontable-skills/skills/hyperformula ~/.claude/skills/` +- Cowork/web: zip from GitHub releases +- API: folder upload + +The published skill has **no Cursor- or Copilot-specific installer**. Do NOT invent one. Cursor/Copilot/Other sections point to `llms-full.txt` + the manual clone, framed honestly. + +- [ ] **Step 1: Write the page** + +Create `docs/guide/setup-coding-agent.md`: + +```markdown +# Set up your coding agent + +HyperFormula ships an official Claude skill and machine-readable docs so your AI coding agent can scaffold, configure, and debug HyperFormula correctly. Pick your tool below, or use the interactive wizard. + + + +## Claude Code + +Install the official skill from the plugin marketplace: + +\``` +/plugin marketplace add handsontable/handsontable-skills +/plugin install handsontable-skills@handsontable-skills +\``` + +Claude Code loads the `hyperformula` skill automatically based on what you ask. + +## Cursor, Copilot & other agents + +These tools don't yet support the Claude skill format. Point your agent at the machine-readable docs instead: + +- **Full corpus:** [`/docs/llms-full.txt`](/llms-full.txt) — the entire documentation in one LLM-friendly file. +- **Per-page Markdown:** append `.md` to any docs URL, or use the **Copy Markdown** button on any page. + +For agents that read a rules file (e.g. Cursor's `AGENTS.md`), add a line pointing at the corpus URL so the agent fetches authoritative docs on demand. + +## Manual install (any Claude Code setup) + +\```bash +git clone https://github.com/handsontable/handsontable-skills.git +cp -r handsontable-skills/skills/hyperformula ~/.claude/skills/ +\``` + +## Resources + +- [Official skill repository](https://github.com/handsontable/handsontable-skills) +- [`llms-full.txt`](/llms-full.txt) +- [API reference](/api/) +``` + +(Note: the `\``` fences above are escaped only in this plan; write real triple-backtick fences in the file.) + +- [ ] **Step 2: Verify links resolve under base** + +Run: `grep -n "llms-full.txt\|handsontable-skills" docs/guide/setup-coding-agent.md` +Expected: links present. `$withBase` is applied by VuePress to root-absolute links like `/llms-full.txt` automatically in rendered output. + +- [ ] **Step 3: Commit** + +```bash +git add docs/guide/setup-coding-agent.md +git commit -m "docs: add Set up your coding agent guide page" +``` + +--- + +## Task 4: C4 — `CodingAgentWizard.vue` interactive component + +**Files:** +- Create: `docs/.vuepress/components/CodingAgentWizard.vue` + +Embedded in the Task 3 page via ``. Local Vue state only. Content per IDE mirrors the verified commands from Task 3 — no invented commands. + +- [ ] **Step 1: Create the component** + +Create `docs/.vuepress/components/CodingAgentWizard.vue`: + +```vue + + + + + +``` + +- [ ] **Step 2: Verify wizard snippets match Task 3 verified commands** + +Run: `grep -c "handsontable/handsontable-skills" docs/.vuepress/components/CodingAgentWizard.vue` +Expected: `>= 2` (Claude Code marketplace + Other/API reference). Confirm NO command absent from the published skill README appears. + +- [ ] **Step 3: Commit** + +```bash +git add docs/.vuepress/components/CodingAgentWizard.vue +git commit -m "feat(docs): add interactive coding-agent setup wizard" +``` + +--- + +## Task 5: C5 — `llms.txt` + `robots.txt` + +**Files:** +- Create: `docs/.vuepress/public/llms.txt` +- Modify: `docs/.vuepress/public/robots.txt` + +Files in `docs/.vuepress/public/` are copied verbatim to the dist root (under base `/docs/`). So `llms.txt` lands at `/docs/llms.txt`. + +- [ ] **Step 1: Create `llms.txt`** + +Create `docs/.vuepress/public/llms.txt`: + +``` +# HyperFormula + +> HyperFormula is an open-source, high-performance calculation engine for spreadsheets and web applications. + +## Docs + +- Guide: https://hyperformula.handsontable.com/docs/guide/ +- API reference: https://hyperformula.handsontable.com/docs/api/ +- Full corpus: https://hyperformula.handsontable.com/docs/llms-full.txt +- Official Claude skill: https://github.com/handsontable/handsontable-skills +``` + +- [ ] **Step 2: Append corpus pointer to `robots.txt`** + +Current `docs/.vuepress/public/robots.txt`: + +``` +User-agent: * +Allow: / + +Sitemap: https://hyperformula.handsontable.com/sitemap.xml +``` + +Add at the end: + +``` +# AI/LLM agent index +# Full documentation corpus: https://hyperformula.handsontable.com/docs/llms-full.txt +``` + +- [ ] **Step 3: Verify files** + +Run: `cat docs/.vuepress/public/llms.txt && echo "---" && tail -3 docs/.vuepress/public/robots.txt` +Expected: both show the corpus URL `.../docs/llms-full.txt`. + +- [ ] **Step 4: Commit** + +```bash +git add docs/.vuepress/public/llms.txt docs/.vuepress/public/robots.txt +git commit -m "docs: add llms.txt and link llms-full.txt from robots.txt" +``` + +--- + +## Task 6: Integration — wire into `config.js` and verify build + +**Files:** +- Modify: `docs/.vuepress/config.js` (require plugin, register in `plugins`, add `globalUIComponents`, add sidebar entry) + +Runs AFTER Tasks 1–5 are merged. + +- [ ] **Step 1: Require the plugin at top of `config.js`** + +Add after the existing `includeCodeSnippet` require (around line 7): + +```js +const mdCompanions = require('./plugins/md-companions'); +``` + +- [ ] **Step 2: Register the plugin in the `plugins` array** + +In the `plugins: [` array (after the sitemap entry), add: + +```js +[mdCompanions, { hostname: DOCS_HOSTNAME }], +``` + +- [ ] **Step 3: Register the global button component** + +Change line 34 from `globalUIComponents: [],` to: + +```js +globalUIComponents: ['CopyMarkdownButton'], +``` + +- [ ] **Step 4: Add sidebar entry for the new page** + +In `themeConfig.sidebar['/']`, inside the `Getting started` group's `children` array, add after the `license-key` entry: + +```js +['/guide/setup-coding-agent', 'Set up your coding agent'], +``` + +- [ ] **Step 5: Run the full docs build** + +Run: `npm run docs:build 2>&1 | tail -20` +Expected: build succeeds, "Generated static files in ...". + +- [ ] **Step 6: Verify companion + corpus output exists** + +Run: +```bash +ls docs/.vuepress/dist/docs/guide/basic-usage.md docs/.vuepress/dist/docs/llms-full.txt docs/.vuepress/dist/docs/llms.txt +echo "--- companion is clean markdown (no \nText after'), + 'Text before\nText after'); + +check('vue component removed', + stripVuePressSyntax('Intro\n\nOutro'), + 'Intro\nOutro'); + +check('toc removed', + stripVuePressSyntax('# Title\n[[toc]]\nBody'), + '# Title\nBody'); + +check('content preserved', + stripVuePressSyntax('# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'), + '# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'); + +console.log(`PASS md-companions/strip (${passed} assertions)`); From 88827eea6662c6f9e160cfdeecf668cfdc73ccc8 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 31 May 2026 22:54:18 +0000 Subject: [PATCH 07/14] feat(docs): add Copy Markdown button global component Co-Authored-By: Claude Sonnet 4.6 --- .../components/CopyMarkdownButton.vue | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 docs/.vuepress/components/CopyMarkdownButton.vue diff --git a/docs/.vuepress/components/CopyMarkdownButton.vue b/docs/.vuepress/components/CopyMarkdownButton.vue new file mode 100644 index 000000000..b6fbf03be --- /dev/null +++ b/docs/.vuepress/components/CopyMarkdownButton.vue @@ -0,0 +1,55 @@ + + + + + From 9613c942aa06b2af481036ae0f4dd142443929dc Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 31 May 2026 22:54:18 +0000 Subject: [PATCH 08/14] feat(docs): add Set up your coding agent page and interactive wizard Install commands traced to handsontable/handsontable-skills; no invented commands. Co-Authored-By: Claude Sonnet 4.6 --- .../components/CodingAgentWizard.vue | 90 +++++++++++++++++++ docs/guide/setup-coding-agent.md | 38 ++++++++ 2 files changed, 128 insertions(+) create mode 100644 docs/.vuepress/components/CodingAgentWizard.vue create mode 100644 docs/guide/setup-coding-agent.md diff --git a/docs/.vuepress/components/CodingAgentWizard.vue b/docs/.vuepress/components/CodingAgentWizard.vue new file mode 100644 index 000000000..8e0cd8869 --- /dev/null +++ b/docs/.vuepress/components/CodingAgentWizard.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/docs/guide/setup-coding-agent.md b/docs/guide/setup-coding-agent.md new file mode 100644 index 000000000..cb06c633f --- /dev/null +++ b/docs/guide/setup-coding-agent.md @@ -0,0 +1,38 @@ +# Set up your coding agent + +HyperFormula ships an official Claude skill and machine-readable docs so your AI coding agent can scaffold, configure, and debug HyperFormula correctly. Pick your tool below, or use the interactive wizard. + + + +## Claude Code + +Install the official skill from the plugin marketplace: + +``` +/plugin marketplace add handsontable/handsontable-skills +/plugin install handsontable-skills@handsontable-skills +``` + +Claude Code loads the `hyperformula` skill automatically based on what you ask. + +## Cursor, Copilot & other agents + +These tools don't yet support the Claude skill format. Point your agent at the machine-readable docs instead: + +- **Full corpus:** [`llms-full.txt`](/llms-full.txt) — the entire documentation in one LLM-friendly file. +- **Per-page Markdown:** append `.md` to any docs URL, or use the **Copy Markdown** button on any page. + +For agents that read a rules file (e.g. Cursor's `AGENTS.md`), add a line pointing at the corpus URL so the agent fetches authoritative docs on demand. + +## Manual install (any Claude Code setup) + +```bash +git clone https://github.com/handsontable/handsontable-skills.git +cp -r handsontable-skills/skills/hyperformula ~/.claude/skills/ +``` + +## Resources + +- [Official skill repository](https://github.com/handsontable/handsontable-skills) +- [`llms-full.txt`](/llms-full.txt) +- [API reference](/api/) From c8f3d5d6944cb7283c63570f0b0c8672c1040d82 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 31 May 2026 22:54:18 +0000 Subject: [PATCH 09/14] docs: add llms.txt and link llms-full.txt from robots.txt Co-Authored-By: Claude Sonnet 4.6 --- docs/.vuepress/public/llms.txt | 10 ++++++++++ docs/.vuepress/public/robots.txt | 3 +++ 2 files changed, 13 insertions(+) create mode 100644 docs/.vuepress/public/llms.txt diff --git a/docs/.vuepress/public/llms.txt b/docs/.vuepress/public/llms.txt new file mode 100644 index 000000000..16bdfd167 --- /dev/null +++ b/docs/.vuepress/public/llms.txt @@ -0,0 +1,10 @@ +# HyperFormula + +> HyperFormula is an open-source, high-performance calculation engine for spreadsheets and web applications. + +## Docs + +- Guide: https://hyperformula.handsontable.com/docs/guide/ +- API reference: https://hyperformula.handsontable.com/docs/api/ +- Full corpus: https://hyperformula.handsontable.com/docs/llms-full.txt +- Official Claude skill: https://github.com/handsontable/handsontable-skills diff --git a/docs/.vuepress/public/robots.txt b/docs/.vuepress/public/robots.txt index ef083139b..8ac6ab69c 100644 --- a/docs/.vuepress/public/robots.txt +++ b/docs/.vuepress/public/robots.txt @@ -2,3 +2,6 @@ User-agent: * Allow: / Sitemap: https://hyperformula.handsontable.com/sitemap.xml + +# AI/LLM agent index +# Full documentation corpus: https://hyperformula.handsontable.com/docs/llms-full.txt From 4e8a008e85e766b93fa56072764721cc27365479 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 31 May 2026 23:00:33 +0000 Subject: [PATCH 10/14] fix(docs): handle :::example and any unknown container types in md-companions Strip.js regex was limited to tip|warning|danger|details. The ::: example container (live code runner) leaked into companion .md files. Broadened match to \w+ and strip example containers entirely (they are demo markup, not prose). Added 8th assertion to the test fixture. Co-Authored-By: Claude Sonnet 4.6 --- docs/.vuepress/plugins/md-companions/strip.js | 5 ++++- docs/.vuepress/plugins/md-companions/strip.test.js | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/.vuepress/plugins/md-companions/strip.js b/docs/.vuepress/plugins/md-companions/strip.js index d8d6f50bf..9195ad458 100644 --- a/docs/.vuepress/plugins/md-companions/strip.js +++ b/docs/.vuepress/plugins/md-companions/strip.js @@ -41,12 +41,15 @@ function stripVuePressSyntax(src) { if (/^\[\[toc\]\]$/i.test(trimmed)) continue; - const open = trimmed.match(/^:::\s*(tip|warning|danger|details)\s*(.*)$/i); + const open = trimmed.match(/^:::\s*(\w+)\s*(.*)$/i); if (open) { + const type = open[1].toLowerCase(); const title = open[2].trim(); const body = []; i++; while (i < lines.length && lines[i].trim() !== ':::') { body.push(lines[i]); i++; } + // Demo/example containers (live code runners) are not prose — omit entirely. + if (type === 'example') { continue; } if (title) { out.push(`> **${title}**`); out.push('>'); } body.forEach(b => out.push(b.trim() === '' ? '>' : `> ${b}`)); while (out.length && out[out.length - 1] === '>') out.pop(); diff --git a/docs/.vuepress/plugins/md-companions/strip.test.js b/docs/.vuepress/plugins/md-companions/strip.test.js index eb5d1dd7f..98d13e7c5 100644 --- a/docs/.vuepress/plugins/md-companions/strip.test.js +++ b/docs/.vuepress/plugins/md-companions/strip.test.js @@ -35,4 +35,9 @@ check('content preserved', stripVuePressSyntax('# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'), '# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'); +// 8. :::example container (live demo) stripped entirely +check('example container stripped', + stripVuePressSyntax('## Demo\n\n::: example #ex1 --html 1\n@[code](example.html)\n:::\n\nOutro'), + '## Demo\n\nOutro'); + console.log(`PASS md-companions/strip (${passed} assertions)`); From d3fff0cda677b3188579e0ce61ddf76245e61750 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 31 May 2026 23:00:54 +0000 Subject: [PATCH 11/14] feat(docs): wire md-companions plugin, Copy Markdown button, and setup page - require md-companions plugin, register with DOCS_HOSTNAME - globalUIComponents: ['CopyMarkdownButton'] - add 'Set up your coding agent' to Getting started sidebar Co-Authored-By: Claude Sonnet 4.6 --- docs/.vuepress/config.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index d185119bc..2e4862a6a 100644 --- a/docs/.vuepress/config.js +++ b/docs/.vuepress/config.js @@ -5,6 +5,7 @@ const searchBoxPlugin = require('./plugins/search-box'); const examples = require('./plugins/examples/examples'); const HyperFormula = require('../../dist/hyperformula.full'); const includeCodeSnippet = require('./plugins/markdown-it-include-code-snippet'); +const mdCompanions = require('./plugins/md-companions'); const searchPattern = new RegExp('^/api', 'i'); @@ -31,7 +32,7 @@ const DOCS_HOSTNAME = process.env.DOCS_HOSTNAME || buildConfigOverrides.hostname module.exports = { title: 'HyperFormula (v' + HyperFormula.version + ')', description: 'HyperFormula is an open-source, high-performance calculation engine for spreadsheets and web applications.', - globalUIComponents: [], + globalUIComponents: ['CopyMarkdownButton'], head: [ // Import HF (required for the examples) [ 'script', { src: 'https://cdn.jsdelivr.net/npm/hyperformula/dist/hyperformula.full.min.js' } ], @@ -89,6 +90,7 @@ module.exports = { exclude: ['/404.html'], changefreq: 'weekly' }], + [mdCompanions, { hostname: DOCS_HOSTNAME }], searchBoxPlugin, ['container', examples()], { @@ -206,6 +208,7 @@ module.exports = { ['/guide/advanced-usage', 'Advanced usage'], ['/guide/configuration-options', 'Configuration options'], ['/guide/license-key', 'License key'], + ['/guide/setup-coding-agent', 'Set up your coding agent'], ] }, { From 89f27b21891e6048f08bd7fb63ab15ccd324ef0c Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Sun, 31 May 2026 23:09:08 +0000 Subject: [PATCH 12/14] fix(docs): address code-review findings in HF-154 components strip.js: - track full fence marker length (CommonMark); inner ``` no longer closes ```` - bail out verbatim on unclosed ::: container (no silent page truncation) - 9th test assertion for nested fence lengths index.js: - wrap per-page work in try/catch; one bad page warns and continues - substitute real base/hostname in corpus header (was literal ) CopyMarkdownButton.vue + CodingAgentWizard.vue: - clipboard fallback via document.execCommand for non-HTTPS contexts - isSecureContext guard + .catch() on Clipboard API promise - null-guard on this.current in wizard copy() setup-coding-agent.md: - relative links to llms-full.txt (../llms-full.txt) to survive any base-path edge cases in static-file link resolution Co-Authored-By: Claude Sonnet 4.6 --- .../components/CodingAgentWizard.vue | 22 +++++++++++++++---- .../components/CopyMarkdownButton.vue | 20 +++++++++++++---- docs/.vuepress/plugins/md-companions/index.js | 20 ++++++++++------- docs/.vuepress/plugins/md-companions/strip.js | 16 +++++++++++--- .../plugins/md-companions/strip.test.js | 5 +++++ docs/guide/setup-coding-agent.md | 4 ++-- 6 files changed, 66 insertions(+), 21 deletions(-) diff --git a/docs/.vuepress/components/CodingAgentWizard.vue b/docs/.vuepress/components/CodingAgentWizard.vue index 8e0cd8869..e5208374e 100644 --- a/docs/.vuepress/components/CodingAgentWizard.vue +++ b/docs/.vuepress/components/CodingAgentWizard.vue @@ -64,10 +64,24 @@ export default { methods: { reset() { this.selected = null; this.copied = false; }, copy() { - navigator.clipboard.writeText(this.current.snippet).then(() => { - this.copied = true; - setTimeout(() => { this.copied = false; }, 1500); - }); + if (!this.current) return; + const text = this.current.snippet; + const fallback = (t) => { + const el = document.createElement('textarea'); + el.value = t; + el.style.position = 'fixed'; + el.style.opacity = '0'; + document.body.appendChild(el); + el.select(); + try { document.execCommand('copy'); } finally { document.body.removeChild(el); } + }; + const done = () => { this.copied = true; setTimeout(() => { this.copied = false; }, 1500); }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(text).then(done).catch(() => { fallback(text); done(); }); + } else { + fallback(text); + done(); + } }, }, }; diff --git a/docs/.vuepress/components/CopyMarkdownButton.vue b/docs/.vuepress/components/CopyMarkdownButton.vue index b6fbf03be..018ec1727 100644 --- a/docs/.vuepress/components/CopyMarkdownButton.vue +++ b/docs/.vuepress/components/CopyMarkdownButton.vue @@ -27,10 +27,22 @@ export default { methods: { copy() { const absolute = window.location.origin + this.mdUrl; - navigator.clipboard.writeText(absolute).then(() => { - this.copied = true; - setTimeout(() => { this.copied = false; }, 1500); - }); + const fallback = (text) => { + const el = document.createElement('textarea'); + el.value = text; + el.style.position = 'fixed'; + el.style.opacity = '0'; + document.body.appendChild(el); + el.select(); + try { document.execCommand('copy'); } finally { document.body.removeChild(el); } + }; + const done = () => { this.copied = true; setTimeout(() => { this.copied = false; }, 1500); }; + if (navigator.clipboard && window.isSecureContext) { + navigator.clipboard.writeText(absolute).then(done).catch(() => { fallback(absolute); done(); }); + } else { + fallback(absolute); + done(); + } }, }, }; diff --git a/docs/.vuepress/plugins/md-companions/index.js b/docs/.vuepress/plugins/md-companions/index.js index 49a508932..ef0c2044c 100644 --- a/docs/.vuepress/plugins/md-companions/index.js +++ b/docs/.vuepress/plugins/md-companions/index.js @@ -19,19 +19,23 @@ module.exports = (options, ctx) => ({ '# HyperFormula Documentation', '', '> Full documentation corpus for LLM consumption.', - '> Individual pages also available at /guide/.md', + `> Individual pages also available at ${hostname}${base}guide/.md`, '', ]; for (const page of pages) { - const clean = stripVuePressSyntax(page._strippedContent || ''); - const relPath = page.path.replace(/\.html$/, '.md'); - const outFile = path.join(ctx.outDir, relPath.replace(/^\//, '')); - await fs.promises.mkdir(path.dirname(outFile), { recursive: true }); - await fs.promises.writeFile(outFile, clean, 'utf8'); + try { + const clean = stripVuePressSyntax(page._strippedContent || ''); + const relPath = page.path.replace(/\.html$/, '.md'); + const outFile = path.join(ctx.outDir, relPath.replace(/^\//, '')); + await fs.promises.mkdir(path.dirname(outFile), { recursive: true }); + await fs.promises.writeFile(outFile, clean, 'utf8'); - const url = hostname + base.replace(/\/$/, '') + page.path.replace(/\.html$/, ''); - corpus.push('---', '', `## ${page.title || page.path}`, '', `URL: ${url}`, '', clean, ''); + const url = hostname + base.replace(/\/$/, '') + page.path.replace(/\.html$/, ''); + corpus.push('---', '', `## ${page.title || page.path}`, '', `URL: ${url}`, '', clean, ''); + } catch (err) { + console.warn(`[md-companions] skipping ${page.path}: ${err.message}`); + } } const llmsFull = path.join(ctx.outDir, 'llms-full.txt'); diff --git a/docs/.vuepress/plugins/md-companions/strip.js b/docs/.vuepress/plugins/md-companions/strip.js index 9195ad458..f6036576f 100644 --- a/docs/.vuepress/plugins/md-companions/strip.js +++ b/docs/.vuepress/plugins/md-companions/strip.js @@ -19,9 +19,12 @@ function stripVuePressSyntax(src) { if (fenceMatch && !inScript) { if (!inFence) { inFence = true; - fenceMarker = fenceMatch[1][0]; - } else if (trimmed.startsWith(fenceMarker)) { - inFence = false; + fenceMarker = fenceMatch[1]; // full marker e.g. "```" or "````" + } else { + const closeMatch = trimmed.match(/^(```+|~~~+)/); + if (closeMatch && closeMatch[1][0] === fenceMarker[0] && closeMatch[1].length >= fenceMarker.length) { + inFence = false; + } } out.push(line); continue; @@ -47,7 +50,14 @@ function stripVuePressSyntax(src) { const title = open[2].trim(); const body = []; i++; + const bodyStart = i; while (i < lines.length && lines[i].trim() !== ':::') { body.push(lines[i]); i++; } + // If we hit EOF without finding closing :::, emit verbatim (not a real container). + if (i >= lines.length) { + out.push(lines[bodyStart - 1]); // re-emit the opening line + body.forEach(b => out.push(b)); + continue; + } // Demo/example containers (live code runners) are not prose — omit entirely. if (type === 'example') { continue; } if (title) { out.push(`> **${title}**`); out.push('>'); } diff --git a/docs/.vuepress/plugins/md-companions/strip.test.js b/docs/.vuepress/plugins/md-companions/strip.test.js index 98d13e7c5..64efa266e 100644 --- a/docs/.vuepress/plugins/md-companions/strip.test.js +++ b/docs/.vuepress/plugins/md-companions/strip.test.js @@ -40,4 +40,9 @@ check('example container stripped', stripVuePressSyntax('## Demo\n\n::: example #ex1 --html 1\n@[code](example.html)\n:::\n\nOutro'), '## Demo\n\nOutro'); +// 9. 4-backtick outer fence is not prematurely closed by 3-backtick inner fence +check('nested fence lengths', + stripVuePressSyntax('````markdown\n```js\ncode\n```\n````'), + '````markdown\n```js\ncode\n```\n````'); + console.log(`PASS md-companions/strip (${passed} assertions)`); diff --git a/docs/guide/setup-coding-agent.md b/docs/guide/setup-coding-agent.md index cb06c633f..af957b569 100644 --- a/docs/guide/setup-coding-agent.md +++ b/docs/guide/setup-coding-agent.md @@ -19,7 +19,7 @@ Claude Code loads the `hyperformula` skill automatically based on what you ask. These tools don't yet support the Claude skill format. Point your agent at the machine-readable docs instead: -- **Full corpus:** [`llms-full.txt`](/llms-full.txt) — the entire documentation in one LLM-friendly file. +- **Full corpus:** [`llms-full.txt`](../llms-full.txt) — the entire documentation in one LLM-friendly file. - **Per-page Markdown:** append `.md` to any docs URL, or use the **Copy Markdown** button on any page. For agents that read a rules file (e.g. Cursor's `AGENTS.md`), add a line pointing at the corpus URL so the agent fetches authoritative docs on demand. @@ -34,5 +34,5 @@ cp -r handsontable-skills/skills/hyperformula ~/.claude/skills/ ## Resources - [Official skill repository](https://github.com/handsontable/handsontable-skills) -- [`llms-full.txt`](/llms-full.txt) +- [`llms-full.txt`](../llms-full.txt) - [API reference](/api/) From c429eeb80a7d1c44a9a4f008ee2ce4f7d1cee774 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 8 Jun 2026 06:38:29 +0000 Subject: [PATCH 13/14] docs(hf-154): add context7.json + GitMCP/Context7 agent doc-access section --- context7.json | 14 ++++++++++++++ docs/guide/setup-coding-agent.md | 7 +++++++ 2 files changed, 21 insertions(+) create mode 100644 context7.json diff --git a/context7.json b/context7.json new file mode 100644 index 000000000..529df2860 --- /dev/null +++ b/context7.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://context7.com/schema/context7.json", + "projectTitle": "HyperFormula", + "description": "Headless, Excel-compatible spreadsheet engine in TypeScript — parses and evaluates ~400 functions in the browser or Node.js. In-process library (no REST API).", + "folders": ["docs"], + "excludeFolders": ["docs/.vuepress/dist", "docs/api"], + "rules": [ + "HyperFormula is an in-process library, not a REST API — there is no HTTP endpoint or base URL.", + "Public API cell addresses are 0-indexed: { sheet, col, row }.", + "There is no #CALC! error type.", + "EmptyValue is exported as a Symbol, not null/undefined.", + "A license key is required when constructing the engine (use 'gpl-v3' for open-source use)." + ] +} diff --git a/docs/guide/setup-coding-agent.md b/docs/guide/setup-coding-agent.md index af957b569..889a5466a 100644 --- a/docs/guide/setup-coding-agent.md +++ b/docs/guide/setup-coding-agent.md @@ -24,6 +24,13 @@ These tools don't yet support the Claude skill format. Point your agent at the m For agents that read a rules file (e.g. Cursor's `AGENTS.md`), add a line pointing at the corpus URL so the agent fetches authoritative docs on demand. +## Live docs via MCP (any agent) + +Two zero-setup ways to let an agent pull authoritative HyperFormula docs on demand — both read this site's `llms.txt`: + +- **GitMCP** — add the MCP server `https://gitmcp.io/handsontable/hyperformula` to your agent (e.g. `claude mcp add --transport http hyperformula https://gitmcp.io/handsontable/hyperformula`). No install, no auth. +- **Context7** — run `npx -y @upstash/context7-mcp` (or use the Context7 skill / `ctx7` CLI) and ask for the `hyperformula` library. Context7 indexes this site's `llms.txt` / `llms-full.txt` (see `context7.json` in the repo root). + ## Manual install (any Claude Code setup) ```bash From f49d15b0c70f7fa1d99743bd5bfd1dbd4749b385 Mon Sep 17 00:00:00 2001 From: marcin-kordas-hoc Date: Mon, 22 Jun 2026 10:44:04 +0000 Subject: [PATCH 14/14] test(docs): run md-companions strip test in CI (HF-154) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The md-companions markdown stripper is the acceptance gate for the agent-friendly docs (per-page .md companions + llms.txt), but its test lived at docs/.vuepress/plugins/md-companions/strip.test.js — a path and suffix Jest's testMatch (`test/**/*spec.(ts|js)`) never discovers, so it never ran in CI. - Move the assertions into test/docs/md-companions-strip.spec.js as a Jest spec, where the default glob picks them up. The file is `.js`, so Karma's `.spec.ts`-only require.context skips it (the stripper is plain Node code, not part of the browser bundle). - Add an ESLint override for `**/test/**/*.spec.js` mirroring the existing `scripts/*.js` relaxations (node env, allow require of untyped modules). - Remove the orphan strip.test.js. - CHANGELOG: note the agent-friendly docs under [Unreleased] > Added. Co-Authored-By: Claude Opus 4.8 --- .eslintrc.js | 18 ++++++ CHANGELOG.md | 1 + .../plugins/md-companions/strip.test.js | 48 --------------- test/docs/md-companions-strip.spec.js | 58 +++++++++++++++++++ 4 files changed, 77 insertions(+), 48 deletions(-) delete mode 100644 docs/.vuepress/plugins/md-companions/strip.test.js create mode 100644 test/docs/md-companions-strip.spec.js diff --git a/.eslintrc.js b/.eslintrc.js index fa0f60c8f..0ae957696 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -165,5 +165,23 @@ module.exports = { 'no-undef': 'off', }, }, + { + // Plain-Node Jest specs (e.g. docs tooling tests that `require` JS modules + // rather than importing typed sources). Same relaxations as `scripts/*.js`. + files: ['**/test/**/*.spec.js'], + env: { + node: true, + }, + rules: { + '@typescript-eslint/no-var-requires': 'off', + '@typescript-eslint/no-unsafe-argument': 'off', + '@typescript-eslint/no-unsafe-assignment': 'off', + '@typescript-eslint/no-unsafe-call': 'off', + '@typescript-eslint/no-unsafe-member-access': 'off', + '@typescript-eslint/no-unsafe-return': 'off', + '@typescript-eslint/restrict-template-expressions': 'off', + 'no-undef': 'off', + }, + }, ], } diff --git a/CHANGELOG.md b/CHANGELOG.md index 683e1da14..5ff2d8700 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added +- Added agent-friendly documentation: per-page `.md` companions, a Copy-Markdown button, an `llms.txt` index, and a coding-agent setup guide. [#1696](https://github.com/handsontable/hyperformula/pull/1696) - Added an Indonesian (Bahasa Indonesia) language pack. [#1674](https://github.com/handsontable/hyperformula/pull/1674) ## [3.3.0] - 2026-05-20 diff --git a/docs/.vuepress/plugins/md-companions/strip.test.js b/docs/.vuepress/plugins/md-companions/strip.test.js deleted file mode 100644 index 64efa266e..000000000 --- a/docs/.vuepress/plugins/md-companions/strip.test.js +++ /dev/null @@ -1,48 +0,0 @@ -const assert = require('assert'); -const { stripVuePressSyntax } = require('./strip'); - -let passed = 0; -const check = (name, actual, expected) => { - assert.strictEqual(actual, expected, `FAIL: ${name}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`); - passed++; -}; - -check('tip container', - stripVuePressSyntax(':::tip Heads up\nBe careful here.\n:::'), - '> **Heads up**\n>\n> Be careful here.'); - -check('warning no title', - stripVuePressSyntax(':::warning\nDanger zone.\n:::'), - '> Danger zone.'); - -check('code fence with ::: inside', - stripVuePressSyntax('```js\nconst x = ":::tip";\n```'), - '```js\nconst x = ":::tip";\n```'); - -check('script removed', - stripVuePressSyntax('Text before\n\nText after'), - 'Text before\nText after'); - -check('vue component removed', - stripVuePressSyntax('Intro\n\nOutro'), - 'Intro\nOutro'); - -check('toc removed', - stripVuePressSyntax('# Title\n[[toc]]\nBody'), - '# Title\nBody'); - -check('content preserved', - stripVuePressSyntax('# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'), - '# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|'); - -// 8. :::example container (live demo) stripped entirely -check('example container stripped', - stripVuePressSyntax('## Demo\n\n::: example #ex1 --html 1\n@[code](example.html)\n:::\n\nOutro'), - '## Demo\n\nOutro'); - -// 9. 4-backtick outer fence is not prematurely closed by 3-backtick inner fence -check('nested fence lengths', - stripVuePressSyntax('````markdown\n```js\ncode\n```\n````'), - '````markdown\n```js\ncode\n```\n````'); - -console.log(`PASS md-companions/strip (${passed} assertions)`); diff --git a/test/docs/md-companions-strip.spec.js b/test/docs/md-companions-strip.spec.js new file mode 100644 index 000000000..b30eefa23 --- /dev/null +++ b/test/docs/md-companions-strip.spec.js @@ -0,0 +1,58 @@ +/** + * CI-discoverable tests for the md-companions VuePress plugin's markdown + * stripper (HF-154, agent-friendly docs). The stripper turns VuePress-flavoured + * markdown into clean markdown for the per-page `.md` companions and `llms.txt`, + * so its fidelity is the acceptance gate for the feature. + * + * This is a `.js` spec under `test/` so Jest's testMatch (`test/**\/*spec.(ts|js)`) + * discovers it, while Karma (which only globs `.spec.ts`) skips it — the stripper + * is plain Node code that does not run in the browser bundle. + */ +const { stripVuePressSyntax } = require('../../docs/.vuepress/plugins/md-companions/strip') + +describe('md-companions stripVuePressSyntax', () => { + it('converts a tip container with a title to a blockquote', () => { + expect(stripVuePressSyntax(':::tip Heads up\nBe careful here.\n:::')) + .toBe('> **Heads up**\n>\n> Be careful here.') + }) + + it('converts a titleless warning container to a blockquote', () => { + expect(stripVuePressSyntax(':::warning\nDanger zone.\n:::')) + .toBe('> Danger zone.') + }) + + it('leaves ::: tokens inside a code fence untouched', () => { + expect(stripVuePressSyntax('```js\nconst x = ":::tip";\n```')) + .toBe('```js\nconst x = ":::tip";\n```') + }) + + it('removes \nText after')) + .toBe('Text before\nText after') + }) + + it('removes self-closing Vue components', () => { + expect(stripVuePressSyntax('Intro\n\nOutro')) + .toBe('Intro\nOutro') + }) + + it('removes the [[toc]] marker', () => { + expect(stripVuePressSyntax('# Title\n[[toc]]\nBody')) + .toBe('# Title\nBody') + }) + + it('preserves plain markdown content', () => { + expect(stripVuePressSyntax('# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|')) + .toBe('# H\n\n`code`\n\n[link](/guide/x)\n\n| a | b |\n|---|---|') + }) + + it('strips :::example (live demo) containers entirely', () => { + expect(stripVuePressSyntax('## Demo\n\n::: example #ex1 --html 1\n@[code](example.html)\n:::\n\nOutro')) + .toBe('## Demo\n\nOutro') + }) + + it('does not let an inner 3-backtick fence close a 4-backtick outer fence', () => { + expect(stripVuePressSyntax('````markdown\n```js\ncode\n```\n````')) + .toBe('````markdown\n```js\ncode\n```\n````') + }) +})