diff --git a/.eslintrc.js b/.eslintrc.js index 7d6108d36b..0ae9576963 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -148,6 +148,40 @@ 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', + }, + }, + { + // 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 683e1da144..5ff2d8700b 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/context7.json b/context7.json new file mode 100644 index 0000000000..529df28606 --- /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/.vuepress/components/CodingAgentWizard.vue b/docs/.vuepress/components/CodingAgentWizard.vue new file mode 100644 index 0000000000..e5208374e2 --- /dev/null +++ b/docs/.vuepress/components/CodingAgentWizard.vue @@ -0,0 +1,104 @@ + + + + + diff --git a/docs/.vuepress/components/CopyMarkdownButton.vue b/docs/.vuepress/components/CopyMarkdownButton.vue new file mode 100644 index 0000000000..018ec1727c --- /dev/null +++ b/docs/.vuepress/components/CopyMarkdownButton.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/docs/.vuepress/config.js b/docs/.vuepress/config.js index d185119bc9..2e4862a6a5 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'], ] }, { diff --git a/docs/.vuepress/plugins/md-companions/index.js b/docs/.vuepress/plugins/md-companions/index.js new file mode 100644 index 0000000000..ef0c2044cc --- /dev/null +++ b/docs/.vuepress/plugins/md-companions/index.js @@ -0,0 +1,44 @@ +const fs = require('fs'); +const path = require('path'); +const { stripVuePressSyntax } = require('./strip'); + +/** + * VuePress plugin: after build, write a clean `.md` companion next to each + * rendered `.html`, plus an aggregate `llms-full.txt`. Respects ctx.outDir + * (which already includes the configured base segment). + * @param {object} options plugin options + * @param {object} ctx VuePress app context + */ +module.exports = (options, ctx) => ({ + name: 'md-companions', + async generated() { + const hostname = (options && options.hostname) || 'https://hyperformula.handsontable.com'; + const base = ctx.base || '/'; + const pages = ctx.pages.filter(p => /\.html$/.test(p.path) && p.path !== '/404.html'); + const corpus = [ + '# HyperFormula Documentation', + '', + '> Full documentation corpus for LLM consumption.', + `> Individual pages also available at ${hostname}${base}guide/.md`, + '', + ]; + + for (const page of pages) { + 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, ''); + } catch (err) { + console.warn(`[md-companions] skipping ${page.path}: ${err.message}`); + } + } + + const llmsFull = path.join(ctx.outDir, 'llms-full.txt'); + await fs.promises.writeFile(llmsFull, corpus.join('\n'), 'utf8'); + } +}); diff --git a/docs/.vuepress/plugins/md-companions/strip.js b/docs/.vuepress/plugins/md-companions/strip.js new file mode 100644 index 0000000000..f6036576fe --- /dev/null +++ b/docs/.vuepress/plugins/md-companions/strip.js @@ -0,0 +1,75 @@ +/** + * 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(); + + const fenceMatch = trimmed.match(/^(```+|~~~+)/); + if (fenceMatch && !inScript) { + if (!inFence) { + inFence = true; + 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; + } + if (inFence) { + out.push(line); + continue; + } + + if (/^<(script|style)[\s>]/i.test(trimmed)) { inScript = true; continue; } + if (inScript) { + if (/<\/(script|style)>/i.test(trimmed)) inScript = false; + continue; + } + + if (/^<[A-Z][A-Za-z0-9]*(\s[^>]*)?\/?>$/.test(trimmed)) continue; + + if (/^\[\[toc\]\]$/i.test(trimmed)) continue; + + const open = trimmed.match(/^:::\s*(\w+)\s*(.*)$/i); + if (open) { + const type = open[1].toLowerCase(); + 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('>'); } + body.forEach(b => out.push(b.trim() === '' ? '>' : `> ${b}`)); + while (out.length && out[out.length - 1] === '>') out.pop(); + continue; + } + + out.push(line); + } + + return out.join('\n').replace(/\n{3,}/g, '\n\n').trim(); +} + +module.exports = { stripVuePressSyntax }; diff --git a/docs/.vuepress/public/llms.txt b/docs/.vuepress/public/llms.txt new file mode 100644 index 0000000000..16bdfd167d --- /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 ef083139b3..8ac6ab69c4 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 diff --git a/docs/guide/setup-coding-agent.md b/docs/guide/setup-coding-agent.md new file mode 100644 index 0000000000..889a5466a2 --- /dev/null +++ b/docs/guide/setup-coding-agent.md @@ -0,0 +1,45 @@ +# 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. + +## 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 +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/) diff --git a/scripts/extract-public-api.js b/scripts/extract-public-api.js new file mode 100644 index 0000000000..5ffd6bcc9b --- /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} File contents, or null when the file is not found. + */ +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 Raw parameter-list text. + * @returns {string[]} Individual parameter strings (whitespace preserved). + */ +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 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[]} */ ([]) + 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 = /^\.{3}?(\w+)/.exec(clean) + 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 Source text of src/index.ts. + * @returns {ApiExport[]} All named exports found in the file. + */ +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 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 [] + /** @type {ApiExport[]} */ + const exports = [] + + // Constructor + const ctorMatch = /\bconstructor\s*\(([^)]*)\)/.exec(src) + 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[]} Top-level directory prefixes included by the TypeScript project. + */ +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 lintScope = extractLintScope() + +/** @type {ApiSurface} */ +const result = { exports: apiExports, lint_scope: lintScope } + +process.stdout.write(JSON.stringify(result) + '\n') diff --git a/test/docs/md-companions-strip.spec.js b/test/docs/md-companions-strip.spec.js new file mode 100644 index 0000000000..b30eefa23b --- /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````') + }) +})