From 5d040dee9b97bbb771341986713b3e369d0fa828 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 16 Apr 2026 09:44:46 +0200 Subject: [PATCH 1/4] ci: add extension docs generation pipeline --- package.json | 10 +- scripts/docs/assembler.mjs | 237 ++++++++++++++++++++ scripts/docs/config.mjs | 120 +++++++++++ scripts/docs/enrich-agent.md | 70 ++++++ scripts/docs/enricher.mjs | 259 ++++++++++++++++++++++ scripts/docs/extractor/constants.mjs | 126 +++++++++++ scripts/docs/extractor/index.mjs | 187 ++++++++++++++++ scripts/docs/extractor/markdown-gen.mjs | 157 ++++++++++++++ scripts/docs/extractor/presets.mjs | 53 +++++ scripts/docs/extractor/regex.mjs | 196 +++++++++++++++++ scripts/docs/generator.mjs | 244 +++++++++++++++++++++ scripts/docs/index.mjs | 116 ++++++++++ scripts/docs/logger.mjs | 25 +++ scripts/docs/utils.mjs | 91 ++++++++ scripts/generate-docs.mjs | 275 ------------------------ 15 files changed, 1890 insertions(+), 276 deletions(-) create mode 100644 scripts/docs/assembler.mjs create mode 100644 scripts/docs/config.mjs create mode 100644 scripts/docs/enrich-agent.md create mode 100644 scripts/docs/enricher.mjs create mode 100644 scripts/docs/extractor/constants.mjs create mode 100644 scripts/docs/extractor/index.mjs create mode 100644 scripts/docs/extractor/markdown-gen.mjs create mode 100644 scripts/docs/extractor/presets.mjs create mode 100644 scripts/docs/extractor/regex.mjs create mode 100644 scripts/docs/generator.mjs create mode 100644 scripts/docs/index.mjs create mode 100644 scripts/docs/logger.mjs create mode 100644 scripts/docs/utils.mjs delete mode 100644 scripts/generate-docs.mjs diff --git a/package.json b/package.json index e4c8fd0b..9c81cbf1 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,15 @@ "scripts": { "ci:deps": "pnpm i --frozen-lockfile", "ci:demo:build": "nx sb:build @markdown-editor/demo", - "ci:docs:build": "node scripts/generate-docs.mjs && npx -y @diplodoc/cli -i ./docs-src -o ./docs-dist", + "ci:docs:build": "node scripts/docs/index.mjs build && npx -y @diplodoc/cli -i ./docs-src -o ./docs-dist", + "docs:extract": "node scripts/docs/index.mjs extract", + "docs:enrich:prompts": "node scripts/docs/index.mjs enrich --mode prompts", + "docs:enrich": "node scripts/docs/index.mjs enrich --mode enrich", + "docs:enrich:apply": "node scripts/docs/index.mjs enrich --mode apply", + "docs:enrich:agent": "echo 'Give the agent: scripts/docs/enrich-agent.md' && echo 'Raw docs: docs-gen/raw/' && echo 'Output: docs-gen/enriched/'", + "docs:assemble": "node scripts/docs/index.mjs generate && node scripts/docs/index.mjs assemble", + "docs:build": "node scripts/docs/index.mjs build && npx -y @diplodoc/cli -i ./docs-src -o ./docs-dist", + "docs:generate": "node scripts/docs/index.mjs extract && node scripts/docs/index.mjs enrich --mode prompts", "ci:test:visual": "nx playwright @markdown-editor/demo", "ci:test:unit": "nx run-many -t test --verbose", "ci:test:esbuild": "nx run-many -t test:esbuild --verbose", diff --git a/scripts/docs/assembler.mjs b/scripts/docs/assembler.mjs new file mode 100644 index 00000000..217f88bf --- /dev/null +++ b/scripts/docs/assembler.mjs @@ -0,0 +1,237 @@ +import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'; +import {basename, join} from 'node:path'; +import process from 'node:process'; + +import {config} from './config.mjs'; +import {logger} from './logger.mjs'; +import {parseFrontmatter, slugify, stripFrontmatter, yamlQuote} from './utils.mjs'; + +const {order: CATEGORY_ORDER, labels: CATEGORY_LABELS} = config.categories; + +/** + * Assembles enriched/raw extension docs into the docs-src/ output directory + */ +export class Assembler { + constructor(docsGenDir, outDir) { + this.docsGenDir = docsGenDir; + this.rawDir = join(docsGenDir, 'raw'); + this.enrichedDir = join(docsGenDir, 'enriched'); + this.irPath = join(docsGenDir, 'extensions.json'); + this.outDir = outDir; + this.extensionsOutDir = join(outDir, 'extensions'); + } + + /** + * Runs the full assembly pipeline + */ + run() { + if (!existsSync(this.rawDir)) { + logger.error(`${this.rawDir} not found. Run extract first.`); + process.exit(1); + } + if (!existsSync(this.outDir)) { + logger.error(`${this.outDir} not found. Run generate first.`); + process.exit(1); + } + + const extensions = existsSync(this.irPath) + ? JSON.parse(readFileSync(this.irPath, 'utf-8')) + : []; + + const version = this.resolveVersion(extensions); + logger.info(`Assembling extension docs for v${version}...`); + + const docs = this.collectDocs(); + logger.info( + `Found ${docs.size} extension docs (enriched: ${[...docs.values()].filter((d) => d.source === 'enriched').length})`, + ); + + const pages = this.writePages(docs, extensions); + this.writeIndex(pages, extensions, version); + + const tocItems = this.generateTocItems(pages); + this.patchTocYaml(tocItems); + this.patchIndexMd(version); + + logger.success(`Assembled ${pages.length} extension pages in ${this.extensionsOutDir}/`); + logger.info('Updated toc.yaml and index.md'); + } + + /** + * Collects docs preferring enriched over raw + */ + collectDocs() { + const docs = new Map(); + + if (existsSync(this.rawDir)) { + for (const file of readdirSync(this.rawDir).filter((f) => f.endsWith('.md'))) { + const name = basename(file, '.md'); + docs.set(name, { + name, + source: 'raw', + content: readFileSync(join(this.rawDir, file), 'utf-8'), + }); + } + } + + if (existsSync(this.enrichedDir)) { + for (const file of readdirSync(this.enrichedDir).filter((f) => f.endsWith('.md'))) { + const name = basename(file, '.md'); + docs.set(name, { + name, + source: 'enriched', + content: readFileSync(join(this.enrichedDir, file), 'utf-8'), + }); + } + } + + return docs; + } + + /** + * Writes individual extension pages to docs-src/extensions/ + */ + writePages(docs, extensions) { + mkdirSync(this.extensionsOutDir, {recursive: true}); + const pages = []; + + for (const [name, doc] of docs) { + const extInfo = extensions.find((e) => e.name === name); + const category = extInfo?.category || parseFrontmatter(doc.content).category || 'other'; + + const slug = slugify(name); + writeFileSync(join(this.extensionsOutDir, `${slug}.md`), stripFrontmatter(doc.content)); + + pages.push({ + name, + slug, + category, + relativePath: `extensions/${slug}.md`, + hasNodes: extInfo?.nodes?.length > 0, + hasMarks: extInfo?.marks?.length > 0, + hasActions: extInfo?.actions?.length > 0, + source: doc.source, + }); + } + + return pages; + } + + /** + * Writes the extensions index page with categorized tables + */ + writeIndex(pages, extensions, version) { + const lines = [ + '# Extensions Reference', + '', + `Documentation generated for \`@gravity-ui/markdown-editor@${version}\`.`, + '', + ]; + + for (const category of CATEGORY_ORDER) { + const categoryPages = pages + .filter((p) => p.category === category) + .sort((a, b) => a.name.localeCompare(b.name)); + if (categoryPages.length === 0) continue; + + lines.push(`## ${CATEGORY_LABELS[category]} Extensions`, ''); + lines.push('| Extension | Nodes | Marks | Actions |'); + lines.push('|-----------|-------|-------|---------|'); + + for (const page of categoryPages) { + const ext = extensions.find((e) => e.name === page.name); + const nodes = ext?.nodes?.join(', ') || '-'; + const marks = ext?.marks?.join(', ') || '-'; + const actions = ext?.actions?.length || 0; + lines.push( + `| [${page.name}](${page.relativePath}) | ${nodes} | ${marks} | ${actions} |`, + ); + } + lines.push(''); + } + + writeFileSync(join(this.outDir, 'extensions-index.md'), lines.join('\n')); + } + + /** + * Generates YAML toc entries for the extensions section + */ + generateTocItems(pages) { + const lines = []; + lines.push(' - name: Extensions'); + lines.push(' href: extensions-index.md'); + lines.push(' items:'); + + for (const category of CATEGORY_ORDER) { + const categoryPages = pages + .filter((p) => p.category === category) + .sort((a, b) => a.name.localeCompare(b.name)); + if (categoryPages.length === 0) continue; + + lines.push(` - name: ${yamlQuote(CATEGORY_LABELS[category])}`); + lines.push(' items:'); + for (const page of categoryPages) { + lines.push(` - name: ${yamlQuote(page.name)}`); + lines.push(` href: ${page.relativePath}`); + } + } + + return lines.join('\n'); + } + + /** + * Patches toc.yaml to include the extensions section + */ + patchTocYaml(extensionsTocItems) { + const tocPath = join(this.outDir, 'toc.yaml'); + + if (!existsSync(tocPath)) { + logger.warn('toc.yaml not found, creating minimal version'); + const content = + [ + 'title: Markdown Editor', + 'href: index.md', + 'items:', + ' - name: Overview', + ' href: index.md', + extensionsTocItems, + ].join('\n') + '\n'; + writeFileSync(tocPath, content); + return; + } + + let content = readFileSync(tocPath, 'utf-8'); + // Remove previous Extensions section before appending fresh one + const extSectionRe = /\n {2}- name: Extensions\n[\s\S]*?(?=\n {2}- name:|\n?$)/; + content = content.replace(extSectionRe, ''); + content = content.trimEnd() + '\n' + extensionsTocItems + '\n'; + writeFileSync(tocPath, content); + } + + /** + * Patches index.md to add a link to the extensions reference + */ + patchIndexMd(version) { + const indexPath = join(this.outDir, 'index.md'); + if (!existsSync(indexPath)) return; + + let content = readFileSync(indexPath, 'utf-8'); + content = content.replace(/\n## Extensions[\s\S]*?(?=\n## |\n?$)/, ''); + content = content.trimEnd() + '\n\n## Extensions\n\n'; + content += `- [Extensions Reference](extensions-index.md) (v${version})\n`; + writeFileSync(indexPath, content); + } + + /** + * Reads version from the first extension's raw doc frontmatter + */ + resolveVersion(extensions) { + if (extensions[0]) { + const raw = join(this.rawDir, `${extensions[0].name}.md`); + if (existsSync(raw)) { + return parseFrontmatter(readFileSync(raw, 'utf-8')).version || 'unknown'; + } + } + return 'unknown'; + } +} diff --git a/scripts/docs/config.mjs b/scripts/docs/config.mjs new file mode 100644 index 00000000..b64a244c --- /dev/null +++ b/scripts/docs/config.mjs @@ -0,0 +1,120 @@ +/** + * Configuration for the extension documentation generation pipeline + */ +export const config = { + ai: { + provider: 'openai', + model: 'gpt-4o-mini', + temperature: 0.3, + maxTokens: 1000, + }, + + prompts: { + description: { + system: `You are a technical writer for the @gravity-ui/markdown-editor library — a ProseMirror-based WYSIWYG and markup editor. Write concise, accurate documentation in English.`, + user: `Write a description of the "{name}" extension (2-4 sentences). +Focus on what this extension adds to the editor from a user's perspective. +Do not repeat the extension name as the first word. + +Category: {category} +ProseMirror nodes: {nodes} +ProseMirror marks: {marks} +Actions: {actions} +Included in presets: {presets} + +Source code: +{sourceCode} + +Write ONLY the description text, no markdown headers.`, + }, + + syntaxGuide: { + system: `You are a technical writer for a markdown editor library. Write clear syntax guides.`, + user: `Write a syntax guide for the "{name}" extension. + +Explain the markdown/markup syntax this extension handles: +- Show the syntax patterns with inline code +- Explain how they render +- Note any variations or edge cases + +If this is a behavior extension with no markdown syntax, write: "This extension does not define custom markdown syntax." + +Metadata: +- Category: {category} +- Input rules: {inputRules} +- Serializer hints: {serializerHints} + +Test examples: +{markupExamples} + +Source code: +{sourceCode} + +Write markdown content without the section header.`, + }, + + serialization: { + system: `You are a technical writer for a markdown editor library.`, + user: `Describe how the "{name}" extension serializes its content back to markdown. + +What markdown output does it produce? Include code examples where helpful. + +If the extension doesn't produce markdown output, write: "This extension does not produce markdown output." + +Serializer hints from code: {serializerHints} +Nodes: {nodes} +Marks: {marks} + +Source code: +{sourceCode} + +Write markdown content without the section header.`, + }, + + useCases: { + system: `You are a technical writer for the @gravity-ui/markdown-editor library.`, + user: `Write 2-4 bullet points describing typical use cases for the "{name}" extension. +When would a developer include this extension in their editor setup? + +Category: {category} +Nodes: {nodes} +Marks: {marks} +Presets: {presets} + +Write ONLY bullet points in markdown. Each should be one concise sentence.`, + }, + + examples: { + system: `You are a technical writer creating markdown documentation examples.`, + user: `Provide 2-3 clear markdown examples for the "{name}" extension. + +Each example should: +1. Have a brief one-line description +2. Show the markdown syntax in a code block +3. Be practical and realistic + +Existing test examples: +{markupExamples} + +Serializer hints: {serializerHints} +Input rules: {inputRules} + +If this extension has no markdown syntax, write: "This extension does not have markdown syntax examples." + +Write in markdown format.`, + }, + }, + + skipEnrichment: ['BaseInputRules', 'BaseKeymap', 'BaseStyles', 'ReactRenderer', 'SharedState'], + + categories: { + order: ['markdown', 'yfm', 'additional', 'behavior', 'base'], + labels: { + markdown: 'Markdown', + yfm: 'YFM', + additional: 'Additional', + behavior: 'Behavior', + base: 'Base', + }, + }, +}; diff --git a/scripts/docs/enrich-agent.md b/scripts/docs/enrich-agent.md new file mode 100644 index 00000000..4a4bdaf3 --- /dev/null +++ b/scripts/docs/enrich-agent.md @@ -0,0 +1,70 @@ +# Enrich Extension Docs — Agent Instructions + +You are enriching documentation for the `@gravity-ui/markdown-editor` library. + +## What to do + +1. Read `docs-gen/extensions.json` to get the list of all extensions and their metadata (IR). +2. For each extension that has a raw doc in `docs-gen/raw/{Name}.md`: + - Read the raw doc file. + - Find all `` markers. + - For each marker, read the extension source code at the path from `dirPath` in the IR, then write a replacement text (see section templates below). + - Write the result to `docs-gen/enriched/{Name}.md` — same content as raw but with markers replaced by your text. +3. Skip these extensions (infrastructure, no user-facing docs needed): + - BaseInputRules, BaseKeymap, BaseStyles, ReactRenderer, SharedState + +## Section templates + +### `description` + +Write 2-4 sentences describing what this extension does from a user's perspective. Do not start with the extension name. Write in English. + +### `serialization` + +Describe what markdown output this extension produces when serializing. Show syntax patterns with inline code. If the extension has no markdown output (behavior extensions), write: "This extension does not produce markdown output." + +### `syntaxGuide` + +Explain the markdown syntax this extension handles. Show patterns, explain how they render, note variations. If no markdown syntax, write: "This extension does not define custom markdown syntax." + +### `useCases` + +Write 2-4 bullet points: when would a developer include this extension? One concise sentence per bullet. + +## How to find source code + +Each extension IR entry has `dirPath` — relative path to the extension directory. Read the key files there: +- `index.ts` — main extension wiring (actions, keymaps, plugins) +- `*Specs/index.ts` — schema, parser, serializer definitions +- `*Specs/const.ts` — node/mark names, attribute enums +- `*Specs/serializer.ts` — how content is serialized to markdown + +## How to write the enriched file + +Take the raw file content, replace each `` with your text, write to `docs-gen/enriched/{Name}.md`. Keep everything else unchanged (frontmatter, deterministic sections, etc.). + +## Scope control + +- `--all` — enrich all extensions (default) +- `--only Bold,Heading,YfmNote` — enrich only listed extensions +- `--category markdown` — enrich only extensions in a category + +When the user gives you this file, they may add a scope line at the bottom. If no scope is specified, do all. + +## Quality guidelines + +- Be accurate — base descriptions on the actual source code, not guesses. +- Be concise — 2-4 sentences for description, not a wall of text. +- Be specific — mention actual syntax like `**text**`, `{% note %}`, `$formula$`. +- Don't invent features that aren't in the code. +- For behavior extensions (Clipboard, History, Search, etc.) the syntaxGuide and serialization sections should say "does not define/produce" rather than being left empty. + +--- + +## Scope + + + + + +--all diff --git a/scripts/docs/enricher.mjs b/scripts/docs/enricher.mjs new file mode 100644 index 00000000..ea42c09e --- /dev/null +++ b/scripts/docs/enricher.mjs @@ -0,0 +1,259 @@ +import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'; +import {basename, join} from 'node:path'; +import process from 'node:process'; + +import {config} from './config.mjs'; +import {logger} from './logger.mjs'; + +const AI_MARKER_RE = //g; + +/** + * Enriches raw extension docs with AI-generated content + */ +export class Enricher { + constructor(docsGenDir) { + this.docsGenDir = docsGenDir; + this.rawDir = join(docsGenDir, 'raw'); + this.enrichedDir = join(docsGenDir, 'enriched'); + this.promptsDir = join(docsGenDir, 'prompts'); + this.irPath = join(docsGenDir, 'extensions.json'); + } + + /** + * Loads extensions IR and raw doc file list + */ + load() { + if (!existsSync(this.rawDir)) { + logger.error(`${this.rawDir} not found. Run extract first.`); + process.exit(1); + } + if (!existsSync(this.irPath)) { + logger.error(`${this.irPath} not found. Run extract first.`); + process.exit(1); + } + + this.extensions = JSON.parse(readFileSync(this.irPath, 'utf-8')); + this.rawFiles = readdirSync(this.rawDir).filter((f) => f.endsWith('.md')); + logger.info(`Found ${this.rawFiles.length} raw docs, ${this.extensions.length} extensions`); + } + + /** + * Generates prompt JSON files for manual AI processing + */ + generatePrompts(opts) { + mkdirSync(this.promptsDir, {recursive: true}); + + let count = 0; + for (const file of this.rawFiles) { + const extName = basename(file, '.md'); + if (opts.only && !opts.only.includes(extName)) continue; + if (config.skipEnrichment?.includes(extName)) continue; + + const rawContent = readFileSync(join(this.rawDir, file), 'utf-8'); + const extInfo = this.extensions.find((e) => e.name === extName); + if (!extInfo) continue; + + const markers = [...rawContent.matchAll(new RegExp(AI_MARKER_RE.source, 'g'))].map( + (m) => m[1], + ); + if (markers.length === 0) continue; + + const sourceCode = this.readExtensionSource(extInfo); + const prompts = {}; + for (const section of markers) { + prompts[section] = this.buildPrompt( + section, + extName, + rawContent, + sourceCode, + extInfo, + ); + } + + writeFileSync( + join(this.promptsDir, `${extName}.json`), + JSON.stringify({extension: extName, prompts}, null, 2), + ); + count++; + } + + return count; + } + + /** + * Enriches raw docs by calling the OpenAI API + */ + async enrichWithAI(opts) { + mkdirSync(this.enrichedDir, {recursive: true}); + + let count = 0; + for (const file of this.rawFiles) { + const extName = basename(file, '.md'); + if (opts.only && !opts.only.includes(extName)) continue; + + const rawContent = readFileSync(join(this.rawDir, file), 'utf-8'); + const extInfo = this.extensions.find((e) => e.name === extName); + if (!extInfo) continue; + + const sourceCode = this.readExtensionSource(extInfo); + let enrichedContent = rawContent; + let enriched = false; + + const replacements = []; + for (const match of rawContent.matchAll(new RegExp(AI_MARKER_RE.source, 'g'))) { + const section = match[1]; + const marker = match[0]; + const prompt = this.buildPrompt(section, extName, rawContent, sourceCode, extInfo); + + logger.info(` Enriching ${extName}.${section}...`); + try { + const result = await this.callOpenAI(prompt, opts.model); + replacements.push({marker, result}); + enriched = true; + } catch (err) { + logger.warn(`failed to enrich ${extName}.${section}: ${err.message}`); + replacements.push({marker, result: ``}); + } + } + + for (const {marker, result} of replacements) { + enrichedContent = enrichedContent.replace(marker, result); + } + + if (enriched) { + writeFileSync(join(this.enrichedDir, `${extName}.md`), enrichedContent); + count++; + } + } + + return count; + } + + /** + * Applies manually prepared AI responses from docs-gen/responses/ directory + */ + applyResponses() { + mkdirSync(this.enrichedDir, {recursive: true}); + const responsesDir = join(this.docsGenDir, 'responses'); + + if (!existsSync(responsesDir)) { + logger.info(`No responses directory found at ${responsesDir}`); + logger.info('To use manual enrichment:'); + logger.info(' 1. Run: node scripts/docs/index.mjs enrich --mode prompts'); + logger.info(` 2. Process prompts from ${this.promptsDir}/`); + logger.info(` 3. Save responses in ${responsesDir}/ExtName.json`); + logger.info(' 4. Run: node scripts/docs/index.mjs enrich --mode apply'); + return 0; + } + + let count = 0; + for (const file of this.rawFiles) { + const extName = basename(file, '.md'); + const responsePath = join(responsesDir, `${extName}.json`); + if (!existsSync(responsePath)) continue; + + const rawContent = readFileSync(join(this.rawDir, file), 'utf-8'); + const responses = JSON.parse(readFileSync(responsePath, 'utf-8')); + + let enrichedContent = rawContent; + for (const [section, text] of Object.entries(responses)) { + enrichedContent = enrichedContent.replace(``, text); + } + + writeFileSync(join(this.enrichedDir, `${extName}.md`), enrichedContent); + count++; + } + + return count; + } + + /** + * Reads relevant source files from an extension directory for AI context + */ + readExtensionSource(extInfo) { + const dir = extInfo.dirPath; + if (!existsSync(dir)) return ''; + + const files = []; + const walk = (d) => { + for (const entry of readdirSync(d, {withFileTypes: true})) { + const full = join(d, entry.name); + if (entry.isDirectory()) { + if (!entry.name.includes('NodeView') && entry.name !== 'node_modules') { + walk(full); + } + } else if (/\.(ts|tsx)$/.test(entry.name) && !entry.name.endsWith('.test.ts')) { + files.push(full); + } + } + }; + walk(dir); + + return files + .slice(0, 8) + .map((f) => { + const content = readFileSync(f, 'utf-8'); + const truncated = + content.length > 3000 ? content.slice(0, 3000) + '\n// ... truncated' : content; + return `--- ${f} ---\n${truncated}`; + }) + .join('\n\n'); + } + + /** + * Builds a prompt string for a given section using config templates + */ + buildPrompt(section, extName, rawContent, sourceCode, extInfo) { + const templateDef = config.prompts[section]; + if (!templateDef) { + return `Describe the "${section}" aspect of the ${extName} extension.`; + } + + const vars = { + name: extName, + category: extInfo.category || 'unknown', + nodes: extInfo.nodes?.join(', ') || 'none', + marks: extInfo.marks?.join(', ') || 'none', + actions: extInfo.actions?.join(', ') || 'none', + presets: extInfo.presets?.join(', ') || 'not in standard presets', + inputRules: extInfo.inputRules?.join(', ') || 'none', + serializerHints: extInfo.serializerHints?.join(', ') || 'none', + markupExamples: extInfo.markupExamples?.map((e) => `- \`${e}\``).join('\n') || 'none', + sourceCode, + rawContent, + }; + + const interpolate = (tpl) => tpl.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? ''); + return `${interpolate(templateDef.system)}\n\n${interpolate(templateDef.user)}`; + } + + /** + * Calls the OpenAI chat completions API + */ + async callOpenAI(prompt, model) { + const apiKey = process.env.OPENAI_API_KEY; + if (!apiKey) throw new Error('OPENAI_API_KEY environment variable is required'); + + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: model || config.ai.model, + messages: [{role: 'user', content: prompt}], + temperature: config.ai.temperature, + max_tokens: config.ai.maxTokens, + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`OpenAI API error ${response.status}: ${text}`); + } + + const data = await response.json(); + return data.choices[0].message.content.trim(); + } +} diff --git a/scripts/docs/extractor/constants.mjs b/scripts/docs/extractor/constants.mjs new file mode 100644 index 00000000..c2a637af --- /dev/null +++ b/scripts/docs/extractor/constants.mjs @@ -0,0 +1,126 @@ +/** + * Extracts constant declarations, enums, and object literals from TypeScript source. + * Returns a Map of name -> resolved string value. + */ +export function extractConstants(content) { + const names = new Map(); + let m; + + // Simple const with string literal: const FOO = 'bar' + const constRe = /(?:export\s+)?const\s+(\w+)\s*=\s*['"]([^'"]+)['"]/g; + while ((m = constRe.exec(content))) { + names.set(m[1], m[2]); + } + + // Enum members: enum Foo { Bar = 'baz' } + const enumRe = /(?:export\s+)?enum\s+(\w+)\s*\{([^}]+)\}/g; + while ((m = enumRe.exec(content))) { + const enumName = m[1]; + const entries = m[2].matchAll(/(\w+)\s*=\s*['"]([^'"]+)['"]/g); + for (const e of entries) { + names.set(`${enumName}.${e[1]}`, e[2]); + } + } + + // Object literal properties: const Obj = { Prop: 'val' | varRef } + const objRe = /(?:export\s+)?const\s+(\w+)\s*=\s*\{([^}]+)\}/gs; + while ((m = objRe.exec(content))) { + const objName = m[1]; + const propRe = /(\w+)\s*:\s*(?:['"]([^'"]+)['"]|(\w+))/g; + let pm; + while ((pm = propRe.exec(m[2]))) { + names.set(`${objName}.${pm[1]}`, pm[2] || pm[3]); + } + } + + // Const-to-const references: const A = B + const refs = []; + const refRe = /(?:export\s+)?const\s+(\w+)\s*=\s*(\w+)\s*;/g; + while ((m = refRe.exec(content))) { + refs.push([m[1], m[2]]); + } + + // Multi-pass resolution for chained references (A -> B -> 'value') + for (let pass = 0; pass < 3; pass++) { + for (const [target, source] of refs) { + if (!names.has(target) && names.has(source)) { + names.set(target, names.get(source)); + } + } + for (const [key, val] of names) { + if (typeof val === 'string' && names.has(val) && key !== val) { + names.set(key, names.get(val)); + } + } + } + + return names; +} + +/** + * Resolves a single raw name against the constants map. + */ +export function resolveConstant(raw, constants) { + if (!raw) return raw; + if (raw.startsWith("'") || raw.startsWith('"')) return raw.slice(1, -1); + + if (constants.has(raw)) { + const val = constants.get(raw); + if (constants.has(val)) return constants.get(val); + return val; + } + + // Try matching as Enum.Member suffix + for (const [key, val] of constants) { + if (key.endsWith(`.${raw}`) || key === raw) return val; + } + return raw; +} + +/** + * Resolves a list of raw names, expanding enum/object prefixes when needed. + */ +export function resolveAllConstants(rawList, constants) { + const resolved = []; + + for (const raw of rawList) { + let val = resolveConstant(raw, constants); + + // Try dotted reference directly + if (val === raw && raw.includes('.') && constants.has(raw)) { + val = constants.get(raw); + } + + // Chase reference chains up to 5 levels deep + let depth = 0; + while (constants.has(val) && depth < 5) { + val = constants.get(val); + depth++; + } + + // If still unresolved, try expanding all members of the prefix + if (val === raw && constants.size > 0) { + const prefix = raw + '.'; + const members = []; + for (const [key, v] of constants) { + if (key.startsWith(prefix)) { + let memberVal = v; + let md = 0; + while (constants.has(memberVal) && md < 5) { + memberVal = constants.get(memberVal); + md++; + } + members.push(memberVal); + } + } + if (members.length > 0) { + resolved.push(...members); + continue; + } + } + + resolved.push(val); + } + + return [...new Set(resolved)]; +} diff --git a/scripts/docs/extractor/index.mjs b/scripts/docs/extractor/index.mjs new file mode 100644 index 00000000..f24ad4a2 --- /dev/null +++ b/scripts/docs/extractor/index.mjs @@ -0,0 +1,187 @@ +import {existsSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'; +import {basename, dirname, join, relative} from 'node:path'; + +import {logger} from '../logger.mjs'; +import {listDirs, readAllTsFiles, readText} from '../utils.mjs'; + +import {extractConstants, resolveAllConstants} from './constants.mjs'; +import {generateRawMd} from './markdown-gen.mjs'; +import {getPresetsForExtension, parsePresets} from './presets.mjs'; +import { + extractActions, + extractAddMark, + extractAddNode, + extractInputRules, + extractKeymaps, + extractMarkSpecs, + extractMdPlugins, + extractNodeSpecs, + extractOptionsType, + extractPlugins, + extractSerializerSyntax, + extractTestExamples, +} from './regex.mjs'; + +const CATEGORIES = ['base', 'behavior', 'markdown', 'yfm', 'additional']; + +/** + * Scans extension directories, builds IR, and generates raw markdown docs + */ +export class ExtensionExtractor { + constructor(editorPkg, outDir) { + this.editorPkg = editorPkg; + this.extensionsDir = join(editorPkg, 'src/extensions'); + this.presetsDir = join(editorPkg, 'src/presets'); + this.outDir = outDir; + this.rawDir = join(outDir, 'raw'); + } + + /** + * Scans a single extension directory and returns its metadata. + */ + scan(extDir, category) { + const name = basename(extDir); + const allFiles = readAllTsFiles(extDir); + const nonTestFiles = allFiles.filter((f) => !f.path.endsWith('.test.ts')); + const allContent = nonTestFiles.map((f) => f.content).join('\n'); + + const constants = extractConstants(allContent); + + const specsFiles = nonTestFiles.filter( + (f) => + f.path.includes('Specs') || + f.path.includes('const') || + f.path.includes('schema') || + f.path.includes('parser') || + (f.path.endsWith('/index.ts') && dirname(f.path) === extDir), + ); + const specsContent = specsFiles.map((f) => f.content).join('\n'); + + const rawNodes = [...extractAddNode(specsContent), ...extractNodeSpecs(specsContent)]; + const rawMarks = [...extractAddMark(specsContent), ...extractMarkSpecs(specsContent)]; + + const nodes = resolveAllConstants(rawNodes, constants); + const marks = resolveAllConstants(rawMarks, constants); + const actions = resolveAllConstants(extractActions(allContent), constants); + const keymaps = extractKeymaps(allContent); + const inputRules = extractInputRules(allContent); + const plugins = [...new Set(extractPlugins(allContent))]; + const mdPlugins = [...new Set(extractMdPlugins(allContent))]; + + const serializerContent = nonTestFiles + .filter((f) => f.path.includes('serializer') || f.path.includes('Specs')) + .map((f) => f.content) + .join('\n'); + const serializerHints = extractSerializerSyntax(serializerContent); + + const indexFile = nonTestFiles.find( + (f) => f.path.endsWith('/index.ts') && dirname(f.path) === extDir, + ); + const options = indexFile ? extractOptionsType(indexFile.content) : []; + const specsIndexFile = nonTestFiles.find( + (f) => f.path.includes('Specs') && f.path.endsWith('/index.ts'), + ); + if (specsIndexFile && options.length === 0) { + options.push(...extractOptionsType(specsIndexFile.content)); + } + + const testFiles = allFiles.filter((f) => f.path.endsWith('.test.ts')); + const markupExamples = testFiles.flatMap((f) => extractTestExamples(f.content)); + + return { + name, + dirPath: relative('.', extDir), + category, + nodes, + marks, + actions, + keymaps, + inputRules, + plugins, + mdPlugins, + serializerHints, + options, + markupExamples: [...new Set(markupExamples)], + presets: [], + }; + } + + /** + * Scans all extension categories and returns full IR array + */ + scanAll() { + const extensions = []; + for (const category of CATEGORIES) { + const catDir = join(this.extensionsDir, category); + for (const dir of listDirs(catDir)) { + const extDir = join(catDir, dir); + try { + extensions.push(this.scan(extDir, category)); + } catch (err) { + logger.warn(`failed to scan ${extDir}: ${err.message}`); + } + } + } + return extensions; + } + + /** + * Runs the full extraction pipeline: scan, resolve presets, write IR + raw docs + */ + run() { + logger.info('Extracting extension documentation...'); + + if (existsSync(this.outDir)) { + rmSync(this.rawDir, {recursive: true, force: true}); + } + mkdirSync(this.rawDir, {recursive: true}); + + const version = this.getEditorVersion(); + logger.info(`Editor version: ${version}`); + + const presetMap = parsePresets(this.presetsDir); + const extensions = this.scanAll(); + + for (const ext of extensions) { + ext.presets = getPresetsForExtension(presetMap, ext.name); + } + + logger.info(`Found ${extensions.length} extensions`); + + writeFileSync(join(this.outDir, 'extensions.json'), JSON.stringify(extensions, null, 2)); + + for (const ext of extensions) { + const rawMd = generateRawMd(ext, presetMap, version); + writeFileSync(join(this.rawDir, `${ext.name}.md`), rawMd); + } + + this.printSummary(extensions); + } + + /** + * Reads the editor package version + */ + getEditorVersion() { + const pkg = JSON.parse(readText(join(this.editorPkg, 'package.json'))); + return pkg.version; + } + + /** + * Prints a summary table of extracted extensions. + */ + printSummary(extensions) { + const summary = extensions.map((e) => { + const parts = [e.name]; + if (e.nodes.length) parts.push(`nodes:${e.nodes.join(',')}`); + if (e.marks.length) parts.push(`marks:${e.marks.join(',')}`); + if (e.actions.length) parts.push(`actions:${e.actions.length}`); + if (e.plugins.length) parts.push(`plugins:${e.plugins.length}`); + return ` ${parts.join(' | ')}`; + }); + + logger.info('\nExtracted extensions:'); + logger.info(summary.join('\n')); + logger.success(`Raw docs written to ${this.rawDir}/`); + logger.success(`IR written to ${join(this.outDir, 'extensions.json')}`); + } +} diff --git a/scripts/docs/extractor/markdown-gen.mjs b/scripts/docs/extractor/markdown-gen.mjs new file mode 100644 index 00000000..29bc05ea --- /dev/null +++ b/scripts/docs/extractor/markdown-gen.mjs @@ -0,0 +1,157 @@ +import {getPresetsForExtension} from './presets.mjs'; + +/** + * Generates a raw markdown doc page for a single extension. + */ +export function generateRawMd(ext, presetMap, version) { + const presets = getPresetsForExtension(presetMap, ext.name); + const lines = []; + + // Frontmatter + lines.push('---'); + lines.push(`extension: ${ext.name}`); + lines.push(`version: ${version}`); + lines.push(`category: ${ext.category}`); + lines.push(`generated: ${new Date().toISOString()}`); + lines.push('---'); + lines.push(''); + lines.push(`# ${ext.name}`); + lines.push(''); + lines.push(''); + lines.push(''); + + // Presets + lines.push('## Presets'); + lines.push(''); + if (presets.length > 0) { + for (const p of presets) lines.push(`- ${p}`); + } else { + lines.push('Not included in any standard preset (use directly).'); + } + lines.push(''); + + // Schema + if (ext.nodes.length > 0 || ext.marks.length > 0) { + lines.push('## Schema'); + lines.push(''); + for (const n of ext.nodes) { + lines.push(`### Node: \`${n}\``); + lines.push(''); + } + for (const m of ext.marks) { + lines.push(`### Mark: \`${m}\``); + lines.push(''); + } + } + + // Actions + if (ext.actions.length > 0) { + lines.push('## Actions'); + lines.push(''); + lines.push('| Action ID |'); + lines.push('|-----------|'); + for (const a of ext.actions) { + lines.push(`| \`${a}\` |`); + } + lines.push(''); + } + + // Keymaps + if (ext.keymaps.length > 0) { + lines.push('## Keymaps'); + lines.push(''); + lines.push('| Key |'); + lines.push('|-----|'); + for (const k of ext.keymaps) { + lines.push(`| \`${k}\` |`); + } + lines.push(''); + } + + // Input Rules + if (ext.inputRules.length > 0) { + lines.push('## Input Rules'); + lines.push(''); + lines.push('| Pattern |'); + lines.push('|---------|'); + for (const r of ext.inputRules) { + lines.push(`| \`${r}\` |`); + } + lines.push(''); + } + + // Markdown Parsing + lines.push('## Markdown Parsing'); + lines.push(''); + if (ext.mdPlugins.length > 0) { + lines.push('Uses markdown-it plugins:'); + lines.push(''); + for (const p of ext.mdPlugins) lines.push(`- \`${p}\``); + } else if (ext.nodes.length > 0 || ext.marks.length > 0) { + lines.push('Uses built-in markdown-it tokens (CommonMark).'); + } else { + lines.push('No markdown parsing.'); + } + lines.push(''); + + // Markdown Serialization + lines.push('## Markdown Serialization'); + lines.push(''); + if (ext.serializerHints.length > 0) { + lines.push('Serializer patterns:'); + lines.push(''); + for (const s of ext.serializerHints) { + const escaped = s.replace(/\|/g, '\\|'); + lines.push(`- \`${escaped}\``); + } + } else { + lines.push(''); + } + lines.push(''); + + // Plugins + if (ext.plugins.length > 0) { + lines.push('## Plugins'); + lines.push(''); + for (const p of ext.plugins) lines.push(`- \`${p}\``); + lines.push(''); + } + + // Options + if (ext.options.length > 0) { + lines.push('## Options'); + lines.push(''); + lines.push('| Option | Type |'); + lines.push('|--------|------|'); + for (const o of ext.options) { + lines.push(`| \`${o.name}\` | \`${o.type}\` |`); + } + lines.push(''); + } + + // Examples from tests + if (ext.markupExamples.length > 0) { + lines.push('## Markup Examples'); + lines.push(''); + lines.push('Extracted from tests:'); + lines.push(''); + for (const ex of ext.markupExamples.slice(0, 10)) { + lines.push('```markdown'); + lines.push(ex); + lines.push('```'); + lines.push(''); + } + } + + // AI placeholder sections + lines.push('## Syntax Guide'); + lines.push(''); + lines.push(''); + lines.push(''); + lines.push('## Use Cases'); + lines.push(''); + lines.push(''); + lines.push(''); + + return lines.join('\n'); +} diff --git a/scripts/docs/extractor/presets.mjs b/scripts/docs/extractor/presets.mjs new file mode 100644 index 00000000..45219935 --- /dev/null +++ b/scripts/docs/extractor/presets.mjs @@ -0,0 +1,53 @@ +import {existsSync} from 'node:fs'; +import {join} from 'node:path'; + +import {readText} from '../utils.mjs'; + +const PRESET_DEFS = [ + {name: 'ZeroPreset', file: 'zero.ts', parent: null}, + {name: 'CommonMarkPreset', file: 'commonmark.ts', parent: 'ZeroPreset'}, + {name: 'DefaultPreset', file: 'default.ts', parent: 'CommonMarkPreset'}, + {name: 'YfmPreset', file: 'yfm.ts', parent: 'DefaultPreset'}, + {name: 'FullPreset', file: 'full.ts', parent: 'YfmPreset'}, +]; + +/** + * Builds a Map of preset name -> list of extension names (with inheritance) + */ +export function parsePresets(presetsDir) { + const presetMap = new Map(); + + for (const def of PRESET_DEFS) { + const filePath = join(presetsDir, def.file); + if (!existsSync(filePath)) continue; + + const content = readText(filePath); + const directUses = []; + const useRe = /\.use\(\s*(\w+)/g; + let m; + while ((m = useRe.exec(content))) { + if (!m[1].endsWith('Preset') && !m[1].endsWith('Specs')) { + directUses.push(m[1]); + } + } + + // Inherit parent preset's extensions + const inherited = def.parent ? presetMap.get(def.parent) || [] : []; + presetMap.set(def.name, [...new Set([...inherited, ...directUses])]); + } + + return presetMap; +} + +/** + * Returns the list of preset names that include a given extension + */ +export function getPresetsForExtension(presetMap, extName) { + const presets = []; + for (const [presetName, extensions] of presetMap) { + if (extensions.includes(extName)) { + presets.push(presetName); + } + } + return presets; +} diff --git a/scripts/docs/extractor/regex.mjs b/scripts/docs/extractor/regex.mjs new file mode 100644 index 00000000..a54c9fb1 --- /dev/null +++ b/scripts/docs/extractor/regex.mjs @@ -0,0 +1,196 @@ +/** + * Extracts ProseMirror node registrations from builder.addNode() calls + */ +export function extractAddNode(content) { + const nodes = []; + // builder.addNode(name, callback) — second arg starts with `(` or line-end + const re = /builder\s*\.addNode\(\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])\s*,\s*(?:\(|$)/gm; + let m; + while ((m = re.exec(content))) { + nodes.push(m[3] || m[1] || m[2]); + } + // Chained: ).addNode(name, ...) + const re2 = /\)\s*\.addNode\(\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])\s*,\s*(?:\(|$)/gm; + while ((m = re2.exec(content))) { + nodes.push(m[3] || m[1] || m[2]); + } + return nodes; +} + +/** + * Extracts ProseMirror mark registrations from builder.addMark() calls + */ +export function extractAddMark(content) { + const marks = []; + // builder.addMark(name, ...) — require builder prefix to avoid matching tr.addMark + const re = /builder\s*\.addMark\(\s*\n?\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])\s*,/g; + let m; + while ((m = re.exec(content))) { + marks.push(m[3] || m[1] || m[2]); + } + // Chained: newline + indent + .addMark — excludes inline tr.addMark + const re2 = /\)\s*\n\s*\.addMark\(\s*\n?\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])\s*,/g; + while ((m = re2.exec(content))) { + marks.push(m[3] || m[1] || m[2]); + } + return marks; +} + +/** + * Extracts node specs from .addNodeSpec({ name: ... }) calls + */ +export function extractNodeSpecs(content) { + const nodes = []; + const re = /\.addNodeSpec\(\s*\{\s*name:\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])/g; + let m; + while ((m = re.exec(content))) { + nodes.push(m[3] || m[1] || m[2]); + } + return nodes; +} + +/** + * Extracts mark specs from .addMarkSpec({ name: ... }) calls + */ +export function extractMarkSpecs(content) { + const marks = []; + const re = /\.addMarkSpec\(\s*\{\s*name:\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])/g; + let m; + while ((m = re.exec(content))) { + marks.push(m[3] || m[1] || m[2]); + } + return marks; +} + +/** + * Extracts action IDs from .addAction() calls + */ +export function extractActions(content) { + const actions = []; + const re = /\.addAction\(\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])/g; + let m; + while ((m = re.exec(content))) { + actions.push(m[3] || m[1] || m[2]); + } + return actions; +} + +/** + * Extracts plugin function names from .addPlugin() calls + */ +export function extractPlugins(content) { + const plugins = []; + const re = /\.addPlugin\(\s*(\w+)/g; + let m; + while ((m = re.exec(content))) { + plugins.push(m[1]); + } + return plugins; +} + +/** + * Extracts keymap bindings from .addKeymap() callbacks + */ +export function extractKeymaps(content) { + const keymaps = []; + const re = /\.addKeymap\(\s*\([^)]*\)\s*=>\s*\(\{([^}]*(?:\{[^}]*\}[^}]*)*)\}\)/gs; + let m; + while ((m = re.exec(content))) { + const block = m[1]; + const keyRe = /['"]?([^'",:]+)['"]?\s*:/g; + let km; + while ((km = keyRe.exec(block))) { + const key = km[1].trim(); + if (key && !key.startsWith('//') && !key.startsWith('...')) { + keymaps.push(key); + } + } + } + return [...new Set(keymaps)]; +} + +/** + * Extracts input rule patterns (markInputRule, wrappingInputRule, etc.) + */ +export function extractInputRules(content) { + const rules = []; + const re = + /(?:markInputRule|textblockTypeInputRule|nodeInputRule|wrappingInputRule|inlineNodeInputRule)\s*\(\s*(?:\/([^/]+)\/|{[^}]*open:\s*'([^']*)'[^}]*close:\s*'([^']*)'[^}]*})/g; + let m; + while ((m = re.exec(content))) { + if (m[1]) { + rules.push(`/${m[1]}/`); + } else if (m[2] && m[3]) { + rules.push(`${m[2]}...${m[3]}`); + } + } + return rules; +} + +/** + * Extracts markdown-it plugin registrations from md.use() calls + */ +export function extractMdPlugins(content) { + const plugins = []; + const re = /md\.use\(\s*(\w+)/g; + let m; + while ((m = re.exec(content))) { + plugins.push(m[1]); + } + return plugins; +} + +/** + * Extracts the Options type fields from `export type FooOptions = { ... }` + */ +export function extractOptionsType(content) { + const fields = []; + const re = /export\s+type\s+\w+Options\s*(?:=\s*(?:\w+\s*&\s*)?)?(?:\{([^}]*)\}|([^;]*))/gs; + const m = re.exec(content); + if (!m) return fields; + const block = m[1] || m[2] || ''; + const fieldRe = /(\w+)\??\s*:\s*([^;]+)/g; + let fm; + while ((fm = fieldRe.exec(block))) { + const name = fm[1].trim(); + const type = fm[2].trim().replace(/\s+/g, ' '); + if (name && !name.startsWith('//')) { + fields.push({name, type}); + } + } + return fields; +} + +/** + * Extracts markup examples from same() assertions in test files + */ +export function extractTestExamples(content) { + const examples = []; + const re = /same\(\s*'([^']+)'/g; + let m; + while ((m = re.exec(content))) { + examples.push(m[1]); + } + const re2 = /same\(\s*`([^`]+)`/g; + while ((m = re2.exec(content))) { + examples.push(m[1]); + } + return examples; +} + +/** + * Extracts serializer syntax patterns from state.write() and state.text() calls + */ +export function extractSerializerSyntax(content) { + const snippets = []; + const writeRe = /state\.write\(\s*[`'"]([^`'"]*)[`'"]/g; + let m; + while ((m = writeRe.exec(content))) { + if (m[1].trim()) snippets.push(m[1]); + } + const textRe = /state\.text\(\s*[`'"]([^`'"]*)[`'"]/g; + while ((m = textRe.exec(content))) { + if (m[1].trim()) snippets.push(m[1]); + } + return snippets; +} diff --git a/scripts/docs/generator.mjs b/scripts/docs/generator.mjs new file mode 100644 index 00000000..c0a6ceeb --- /dev/null +++ b/scripts/docs/generator.mjs @@ -0,0 +1,244 @@ +import { + cpSync, + existsSync, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import {dirname, join} from 'node:path'; +import process from 'node:process'; + +import {logger} from './logger.mjs'; +import {slugify, yamlQuote} from './utils.mjs'; + +// Source docs use ##### as a metadata header: "##### Category / Title" +const HEADER_RE = /^#{5}\s+(.+)$/; + +const GITHUB_RAW_RE = + /https:\/\/raw\.githubusercontent\.com\/gravity-ui\/markdown-editor\/(?:refs\/heads\/[^/]+|[^/]+)\/docs\//g; + +/** + * Generates the Diplodoc docs-src/ site from hand-written docs/ markdown files + */ +export class Generator { + constructor(docsDir, outDir) { + this.docsDir = docsDir; + this.outDir = outDir; + } + + /** + * Runs the full generation pipeline + */ + run() { + this.clean(); + + const docs = this.collectDocs(); + const {categories, topLevel} = this.groupByCategory(docs); + + this.writeYfmConfig(); + this.writeDocFiles(docs); + this.writeTocYaml(categories, topLevel); + this.writeIndexMd(categories, topLevel); + this.copyAssets(); + + logger.success( + `Generated docs-src/: ${docs.length} pages in ${categories.size} categories + ${topLevel.length} top-level`, + ); + } + + /** + * Removes and recreates the output directory + */ + clean() { + if (existsSync(this.outDir)) { + rmSync(this.outDir, {recursive: true, force: true}); + } + mkdirSync(this.outDir, {recursive: true}); + } + + /** + * Reads all markdown files and parses their ##### headers + */ + collectDocs() { + if (!existsSync(this.docsDir)) { + logger.error(`source directory "${this.docsDir}" does not exist`); + process.exit(1); + } + + const files = readdirSync(this.docsDir) + .filter((f) => f.endsWith('.md')) + .sort(); + const docs = []; + + for (const file of files) { + const content = readFileSync(join(this.docsDir, file), 'utf-8'); + const lines = content.split('\n'); + const parsed = this.parseHeader(lines[0]); + + if (!parsed) { + logger.warn(`Skipping ${file}: no ##### header found`); + continue; + } + + docs.push({ + sourceFile: file, + category: parsed.category, + title: parsed.title, + content: lines.slice(1).join('\n').replace(/^\n+/, ''), + }); + } + + return docs; + } + + /** + * Extracts category and title from a ##### header line + */ + parseHeader(firstLine) { + const match = firstLine.match(HEADER_RE); + if (!match) return null; + + const raw = match[1].trim(); + const parts = raw.split('/').map((s) => s.trim()); + return parts.length === 2 + ? {category: parts[0], title: parts[1]} + : {category: null, title: parts[0]}; + } + + /** + * Splits docs into categorized and top-level groups + */ + groupByCategory(docs) { + const categories = new Map(); + const topLevel = []; + + for (const doc of docs) { + if (doc.category) { + if (!categories.has(doc.category)) categories.set(doc.category, []); + categories.get(doc.category).push(doc); + } else { + topLevel.push(doc); + } + } + + return {categories, topLevel}; + } + + /** + * Computes relative output path from doc category and title slugs + */ + computeOutputPath(doc) { + if (doc.category) { + return join(slugify(doc.category), slugify(doc.title) + '.md'); + } + return slugify(doc.title) + '.md'; + } + + /** + * Rewrites absolute GitHub raw URLs to relative paths + */ + rewriteAssetUrls(content, doc) { + const prefix = doc.category ? '../' : './'; + return content.replace(GITHUB_RAW_RE, prefix); + } + + /** + * Writes all doc pages, checking for duplicate output paths + */ + writeDocFiles(docs) { + const seen = new Map(); + for (const doc of docs) { + const outPath = this.computeOutputPath(doc); + if (seen.has(outPath)) { + logger.error( + `duplicate output path "${outPath}" from "${doc.sourceFile}" and "${seen.get(outPath)}"`, + ); + process.exit(1); + } + seen.set(outPath, doc.sourceFile); + } + + for (const doc of docs) { + const outPath = join(this.outDir, this.computeOutputPath(doc)); + mkdirSync(dirname(outPath), {recursive: true}); + writeFileSync(outPath, this.rewriteAssetUrls(doc.content, doc)); + } + } + + /** + * Generates toc.yaml for the Diplodoc site + */ + writeTocYaml(categories, topLevel) { + const lines = [ + 'title: Markdown Editor', + 'href: index.md', + 'items:', + ' - name: Overview', + ' href: index.md', + ]; + + for (const [category, docs] of categories) { + lines.push(` - name: ${yamlQuote(category)}`); + lines.push(' items:'); + for (const doc of docs) { + lines.push(` - name: ${yamlQuote(doc.title)}`); + lines.push(` href: ${this.computeOutputPath(doc)}`); + } + } + + for (const doc of topLevel) { + lines.push(` - name: ${yamlQuote(doc.title)}`); + lines.push(` href: ${this.computeOutputPath(doc)}`); + } + + writeFileSync(join(this.outDir, 'toc.yaml'), lines.join('\n') + '\n'); + } + + /** + * Generates the index.md landing page + */ + writeIndexMd(categories, topLevel) { + const lines = [ + '# Markdown Editor', + '', + 'Documentation for the Gravity UI Markdown Editor.', + '', + ]; + + for (const [category, docs] of categories) { + lines.push(`## ${category}`, ''); + for (const doc of docs) { + lines.push(`- [${doc.title}](${this.computeOutputPath(doc)})`); + } + lines.push(''); + } + + if (topLevel.length > 0) { + for (const doc of topLevel) { + lines.push(`- [${doc.title}](${this.computeOutputPath(doc)})`); + } + lines.push(''); + } + + writeFileSync(join(this.outDir, 'index.md'), lines.join('\n')); + } + + /** + * Copies the assets/ directory to the output + */ + copyAssets() { + const assetsDir = join(this.docsDir, 'assets'); + if (existsSync(assetsDir)) { + cpSync(assetsDir, join(this.outDir, 'assets'), {recursive: true}); + } + } + + /** + * Writes the .yfm Diplodoc config file + */ + writeYfmConfig() { + writeFileSync(join(this.outDir, '.yfm'), 'allowHTML: true\n'); + } +} diff --git a/scripts/docs/index.mjs b/scripts/docs/index.mjs new file mode 100644 index 00000000..00eee390 --- /dev/null +++ b/scripts/docs/index.mjs @@ -0,0 +1,116 @@ +import process from 'node:process'; + +import {Assembler} from './assembler.mjs'; +import {Enricher} from './enricher.mjs'; +import {ExtensionExtractor} from './extractor/index.mjs'; +import {Generator} from './generator.mjs'; +import {logger} from './logger.mjs'; + +const EDITOR_PKG = 'packages/editor'; +const DOCS_DIR = 'docs'; +const DOCS_SRC_DIR = 'docs-src'; +const DOCS_GEN_DIR = 'docs-gen'; + +/** + * Parses CLI arguments into a command and options object + */ +function parseArgs() { + const args = process.argv.slice(2); + const command = args[0]; + const opts = {mode: 'prompts', only: null, model: null}; + + for (let i = 1; i < args.length; i++) { + switch (args[i]) { + case '--mode': + opts.mode = args[++i]; + break; + case '--only': + opts.only = args[++i]?.split(','); + break; + case '--model': + opts.model = args[++i]; + break; + } + } + + return {command, opts}; +} + +function runGenerate() { + new Generator(DOCS_DIR, DOCS_SRC_DIR).run(); +} + +function runExtract() { + new ExtensionExtractor(EDITOR_PKG, DOCS_GEN_DIR).run(); +} + +async function runEnrich(opts) { + const enricher = new Enricher(DOCS_GEN_DIR); + enricher.load(); + + switch (opts.mode) { + case 'prompts': { + const count = enricher.generatePrompts(opts); + logger.success(`Generated ${count} prompt files in ${DOCS_GEN_DIR}/prompts/`); + logger.info('\nNext steps:'); + logger.info(' - Process prompts through your AI tool'); + logger.info(` - Save responses in ${DOCS_GEN_DIR}/responses/ExtName.json`); + logger.info(' - Run: node scripts/docs/index.mjs enrich --mode apply'); + logger.info('\nOr with OpenAI API:'); + logger.info(' OPENAI_API_KEY=sk-... node scripts/docs/index.mjs enrich --mode enrich'); + break; + } + case 'enrich': { + const count = await enricher.enrichWithAI(opts); + logger.success(`Enriched ${count} docs in ${DOCS_GEN_DIR}/enriched/`); + break; + } + case 'apply': { + const count = enricher.applyResponses(); + logger.success(`Applied responses to ${count} docs in ${DOCS_GEN_DIR}/enriched/`); + break; + } + default: + logger.error(`Unknown enrich mode: ${opts.mode}. Use --mode prompts|enrich|apply`); + process.exit(1); + } +} + +function runAssemble() { + new Assembler(DOCS_GEN_DIR, DOCS_SRC_DIR).run(); +} + +/** + * Full pipeline: generate -> extract -> assemble + */ +function runBuild() { + runGenerate(); + runExtract(); + runAssemble(); +} + +async function main() { + const {command, opts} = parseArgs(); + + const commands = { + generate: runGenerate, + extract: runExtract, + enrich: () => runEnrich(opts), + assemble: runAssemble, + build: runBuild, + }; + + const handler = commands[command]; + if (!handler) { + logger.error(`Unknown command: ${command}`); + logger.info('Available commands: generate, extract, enrich, assemble, build'); + process.exit(1); + } + + await handler(); +} + +main().catch((err) => { + logger.error(err); + process.exit(1); +}); diff --git a/scripts/docs/logger.mjs b/scripts/docs/logger.mjs new file mode 100644 index 00000000..ddb71c62 --- /dev/null +++ b/scripts/docs/logger.mjs @@ -0,0 +1,25 @@ +/* eslint-disable no-console */ + +/** + * Logging utility for doc-generation scripts + */ +export class Logger { + info(...args) { + console.log(...args); + } + + warn(...args) { + console.warn('Warning:', ...args); + } + + error(...args) { + console.error('Error:', ...args); + } + + success(...args) { + console.log('✓', ...args); + } +} + +/** Shared logger instance. */ +export const logger = new Logger(); diff --git a/scripts/docs/utils.mjs b/scripts/docs/utils.mjs new file mode 100644 index 00000000..64fd8e23 --- /dev/null +++ b/scripts/docs/utils.mjs @@ -0,0 +1,91 @@ +import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs'; +import {join} from 'node:path'; + +/** + * Converts a string to a URL-friendly slug + */ +export function slugify(str) { + return str + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, ''); +} + +/** + * Wraps a string in double quotes if it contains YAML special characters + */ +export function yamlQuote(str) { + if (/[:#"'{}[\],&*?|>!%@`]/.test(str)) { + return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; + } + return str; +} + +/** + * Reads a file as UTF-8 text + */ +export function readText(filePath) { + return readFileSync(filePath, 'utf-8'); +} + +/** + * Lists subdirectories starting with an uppercase letter + */ +export function listDirs(dir) { + if (!existsSync(dir)) return []; + return readdirSync(dir).filter((name) => { + const full = join(dir, name); + return statSync(full).isDirectory() && /^[A-Z]/.test(name); + }); +} + +/** + * Recursively finds files matching a regex pattern + */ +export function findFiles(dir, pattern) { + const results = []; + if (!existsSync(dir)) return results; + for (const entry of readdirSync(dir, {recursive: true})) { + if (pattern.test(entry)) { + results.push(join(dir, entry)); + } + } + return results; +} + +/** + * Reads all .ts/.tsx files in a directory (recursive) + */ +export function readAllTsFiles(dir) { + const files = findFiles(dir, /\.tsx?$/); + return files.map((f) => ({path: f, content: readText(f)})); +} + +/** + * Strips YAML frontmatter from markdown content + */ +export function stripFrontmatter(content) { + if (content.startsWith('---')) { + const end = content.indexOf('---', 3); + if (end !== -1) { + return content.slice(end + 3).replace(/^\n+/, ''); + } + } + return content; +} + +/** + * Parses simple key-value YAML frontmatter into an object + */ +export function parseFrontmatter(content) { + if (!content.startsWith('---')) return {}; + const end = content.indexOf('---', 3); + if (end === -1) return {}; + const yaml = content.slice(3, end).trim(); + const result = {}; + for (const line of yaml.split('\n')) { + const match = line.match(/^(\w+):\s*(.+)$/); + if (match) result[match[1]] = match[2].trim(); + } + return result; +} diff --git a/scripts/generate-docs.mjs b/scripts/generate-docs.mjs deleted file mode 100644 index b41cf440..00000000 --- a/scripts/generate-docs.mjs +++ /dev/null @@ -1,275 +0,0 @@ -import { - cpSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import {dirname, join} from 'node:path'; -import process from 'node:process'; - -const DOCS_DIR = 'docs'; -const OUT_DIR = 'docs-src'; -const GITHUB_RAW_RE = - /https:\/\/raw\.githubusercontent\.com\/gravity-ui\/markdown-editor\/(?:refs\/heads\/[^/]+|[^/]+)\/docs\//g; - -// Source docs use ##### as a metadata header (not rendered). -// Format: "##### Category / Title" or "##### Title" (no category). -// This line is stripped from the output; the rest becomes the page content. -const HEADER_RE = /^#{5}\s+(.+)$/; - -/** - * Converts a string to a URL-friendly slug (lowercase, alphanumeric, hyphens). - * @param str - */ -function slugify(str) { - return str - .toLowerCase() - .replace(/[^a-z0-9]+/g, '-') - .replace(/(^-|-$)/g, ''); -} - -/** - * Extracts category and title from a `##### Category / Title` header line. - * @param firstLine - */ -function parseHeader(firstLine) { - const match = firstLine.match(HEADER_RE); - if (!match) return null; - - const raw = match[1].trim(); - const parts = raw.split('/').map((s) => s.trim()); - - if (parts.length === 2) { - return {category: parts[0], title: parts[1]}; - } - return {category: null, title: parts[0]}; -} - -/** Removes all generated content from the output directory. */ -function cleanOutDir() { - if (existsSync(OUT_DIR)) { - rmSync(OUT_DIR, {recursive: true, force: true}); - } - mkdirSync(OUT_DIR, {recursive: true}); -} - -/** Reads all markdown files from the source directory and parses their headers. */ -function collectDocs() { - if (!existsSync(DOCS_DIR)) { - console.error(`Error: source directory "${DOCS_DIR}" does not exist`); - process.exit(1); - } - - const files = readdirSync(DOCS_DIR) - .filter((f) => f.endsWith('.md')) - .sort(); - const docs = []; - - for (const file of files) { - const content = readFileSync(join(DOCS_DIR, file), 'utf-8'); - const lines = content.split('\n'); - const parsed = parseHeader(lines[0]); - - if (!parsed) { - console.warn(`Skipping ${file}: no ##### header found`); - continue; - } - - const strippedContent = lines.slice(1).join('\n').replace(/^\n+/, ''); - - docs.push({ - sourceFile: file, - category: parsed.category, - title: parsed.title, - content: strippedContent, - }); - } - - return docs; -} - -/** - * Splits docs into a category map and a top-level (uncategorized) list. - * @param docs - */ -function groupByCategory(docs) { - const categories = new Map(); - const topLevel = []; - - for (const doc of docs) { - if (doc.category) { - if (!categories.has(doc.category)) { - categories.set(doc.category, []); - } - categories.get(doc.category).push(doc); - } else { - topLevel.push(doc); - } - } - - return {categories, topLevel}; -} - -/** - * Builds a relative output file path from the doc's category and title slugs. - * @param doc - */ -function computeOutputPath(doc) { - if (doc.category) { - return join(slugify(doc.category), slugify(doc.title) + '.md'); - } - return slugify(doc.title) + '.md'; -} - -/** - * Ensures no two docs resolve to the same output path; exits on collision. - * @param docs - */ -function checkDuplicatePaths(docs) { - const seen = new Map(); - for (const doc of docs) { - const outPath = computeOutputPath(doc); - if (seen.has(outPath)) { - console.error( - `Error: duplicate output path "${outPath}" from "${doc.sourceFile}" and "${seen.get(outPath)}"`, - ); - process.exit(1); - } - seen.set(outPath, doc.sourceFile); - } -} - -/** - * Rewrites absolute GitHub raw URLs to relative paths based on doc nesting depth. - * @param content - * @param doc - */ -function rewriteAssetUrls(content, doc) { - const prefix = doc.category ? '../' : './'; - return content.replace(GITHUB_RAW_RE, prefix); -} - -/** - * Writes stripped markdown content to categorized output paths. - * @param docs - */ -function writeDocFiles(docs) { - checkDuplicatePaths(docs); - for (const doc of docs) { - const outPath = join(OUT_DIR, computeOutputPath(doc)); - mkdirSync(dirname(outPath), {recursive: true}); - writeFileSync(outPath, rewriteAssetUrls(doc.content, doc)); - } -} - -/** - * Wraps a string in double quotes if it contains YAML special characters. - * @param str - */ -function yamlQuote(str) { - if (/[:#"'{}[\],&*?|>!%@`]/.test(str)) { - return `"${str.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; - } - return str; -} - -/** - * Generates the `toc.yaml` table of contents for the YFM documentation site. - * @param categories - * @param topLevel - */ -function generateTocYaml(categories, topLevel) { - const lines = [ - 'title: Markdown Editor', - 'href: index.md', - 'items:', - ' - name: Overview', - ' href: index.md', - ]; - - for (const [category, docs] of categories) { - lines.push(` - name: ${yamlQuote(category)}`); - lines.push(' items:'); - for (const doc of docs) { - lines.push(` - name: ${yamlQuote(doc.title)}`); - lines.push(` href: ${computeOutputPath(doc)}`); - } - } - - for (const doc of topLevel) { - lines.push(` - name: ${yamlQuote(doc.title)}`); - lines.push(` href: ${computeOutputPath(doc)}`); - } - - writeFileSync(join(OUT_DIR, 'toc.yaml'), lines.join('\n') + '\n'); -} - -/** - * Generates the `index.md` landing page with links to all doc pages. - * @param categories - * @param topLevel - */ -function generateIndexMd(categories, topLevel) { - const lines = [ - '# Markdown Editor', - '', - 'Documentation for the Gravity UI Markdown Editor.', - '', - ]; - - for (const [category, docs] of categories) { - lines.push(`## ${category}`, ''); - for (const doc of docs) { - lines.push(`- [${doc.title}](${computeOutputPath(doc)})`); - } - lines.push(''); - } - - if (topLevel.length > 0) { - for (const doc of topLevel) { - lines.push(`- [${doc.title}](${computeOutputPath(doc)})`); - } - lines.push(''); - } - - writeFileSync(join(OUT_DIR, 'index.md'), lines.join('\n')); -} - -/** Copies the `assets/` directory from source docs to the output directory. */ -function copyAssets() { - const assetsDir = join(DOCS_DIR, 'assets'); - if (existsSync(assetsDir)) { - cpSync(assetsDir, join(OUT_DIR, 'assets'), {recursive: true}); - } -} - -/** Writes the `.yfm` Diplodoc config into the output directory. */ -function writeYfmConfig() { - writeFileSync(join(OUT_DIR, '.yfm'), 'allowHTML: true\n'); -} - -/** Entry point: cleans output, collects docs, and generates the documentation site. */ -function main() { - cleanOutDir(); - - const docs = collectDocs(); - const {categories, topLevel} = groupByCategory(docs); - - writeYfmConfig(); - writeDocFiles(docs); - generateTocYaml(categories, topLevel); - generateIndexMd(categories, topLevel); - copyAssets(); - - const totalFiles = docs.length; - const totalCategories = categories.size; - // eslint-disable-next-line no-console - console.log( - `Generated docs-src/: ${totalFiles} pages in ${totalCategories} categories + ${topLevel.length} top-level`, - ); -} - -main(); From c65ebba3238a721bb862b9c4b57cc0dbf1b0d74b Mon Sep 17 00:00:00 2001 From: Sergey Makhnatkin Date: Wed, 6 May 2026 17:39:58 +0200 Subject: [PATCH 2/4] fix: clear stale enriched docs before docs build (#1128) --- scripts/docs/index.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/scripts/docs/index.mjs b/scripts/docs/index.mjs index 00eee390..34cb969c 100644 --- a/scripts/docs/index.mjs +++ b/scripts/docs/index.mjs @@ -1,3 +1,4 @@ +import {existsSync, rmSync} from 'node:fs'; import process from 'node:process'; import {Assembler} from './assembler.mjs'; @@ -80,10 +81,20 @@ function runAssemble() { new Assembler(DOCS_GEN_DIR, DOCS_SRC_DIR).run(); } +function clearEnrichedDocs() { + const enrichedDir = `${DOCS_GEN_DIR}/enriched`; + + if (existsSync(enrichedDir)) { + logger.info(`Removing stale enriched docs from ${enrichedDir}/`); + rmSync(enrichedDir, {recursive: true, force: true}); + } +} + /** * Full pipeline: generate -> extract -> assemble */ function runBuild() { + clearEnrichedDocs(); runGenerate(); runExtract(); runAssemble(); From 071c7f4e02d87490115ab56932aa6ea022e006b0 Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 7 May 2026 00:16:43 +0200 Subject: [PATCH 3/4] ci: make extension docs build strict --- .gitignore | 3 + AGENTS.md | 1 + docs-gen/enriched/Autocomplete.md | 29 ++ docs-gen/enriched/BaseSchema.md | 69 +++ docs-gen/enriched/Blockquote.md | 78 ++++ docs-gen/enriched/Bold.md | 78 ++++ docs-gen/enriched/Breaks.md | 58 +++ docs-gen/enriched/Checkbox.md | 93 ++++ docs-gen/enriched/ClicksOnEdges.md | 37 ++ docs-gen/enriched/Clipboard.md | 34 ++ docs-gen/enriched/Code.md | 75 ++++ docs-gen/enriched/CodeBlock.md | 100 +++++ docs-gen/enriched/Color.md | 71 ++++ docs-gen/enriched/CommandMenu.md | 36 ++ docs-gen/enriched/Cursor.md | 35 ++ docs-gen/enriched/Deflist.md | 54 +++ docs-gen/enriched/EditorModeKeymap.md | 35 ++ docs-gen/enriched/Emoji.md | 53 +++ docs-gen/enriched/FilePaste.md | 29 ++ docs-gen/enriched/FoldingHeading.md | 50 +++ docs-gen/enriched/GPT.md | 36 ++ docs-gen/enriched/Heading.md | 103 +++++ docs-gen/enriched/History.md | 45 ++ docs-gen/enriched/HorizontalRule.md | 71 ++++ docs-gen/enriched/Html.md | 54 +++ docs-gen/enriched/Image.md | 59 +++ docs-gen/enriched/ImgSize.md | 76 ++++ docs-gen/enriched/Italic.md | 78 ++++ docs-gen/enriched/Link.md | 94 +++++ docs-gen/enriched/Lists.md | 119 ++++++ docs-gen/enriched/Mark.md | 65 +++ docs-gen/enriched/Math.md | 140 ++++++ docs-gen/enriched/Mermaid.md | 61 +++ docs-gen/enriched/Monospace.md | 66 +++ docs-gen/enriched/Placeholder.md | 29 ++ docs-gen/enriched/QuoteLink.md | 57 +++ docs-gen/enriched/Resizable.md | 29 ++ docs-gen/enriched/Search.md | 29 ++ docs-gen/enriched/Selection.md | 34 ++ docs-gen/enriched/SelectionContext.md | 37 ++ docs-gen/enriched/Strike.md | 71 ++++ docs-gen/enriched/Subscript.md | 71 ++++ docs-gen/enriched/Superscript.md | 71 ++++ docs-gen/enriched/Table.md | 83 ++++ docs-gen/enriched/Underline.md | 72 ++++ docs-gen/enriched/Video.md | 77 ++++ docs-gen/enriched/WidgetDecoration.md | 34 ++ docs-gen/enriched/YfmConfigs.md | 42 ++ docs-gen/enriched/YfmCut.md | 80 ++++ docs-gen/enriched/YfmFile.md | 82 ++++ docs-gen/enriched/YfmHeading.md | 71 ++++ docs-gen/enriched/YfmHtmlBlock.md | 57 +++ docs-gen/enriched/YfmNote.md | 79 ++++ docs-gen/enriched/YfmTable.md | 95 +++++ docs-gen/enriched/YfmTabs.md | 101 +++++ docs/how-to-generate-extension-docs.md | 39 ++ package.json | 5 +- scripts/docs/assembler.mjs | 89 +++- scripts/docs/assembler.test.mjs | 124 ++++++ scripts/docs/config.mjs | 8 +- scripts/docs/enrich-agent.md | 8 + scripts/docs/enricher.mjs | 17 +- scripts/docs/extractor/index.mjs | 2 + scripts/docs/extractor/markdown-gen.mjs | 1 - scripts/docs/extractor/regex.mjs | 540 +++++++++++++++++++++++- scripts/docs/extractor/regex.test.mjs | 65 +++ scripts/docs/generator.mjs | 13 +- scripts/docs/index.mjs | 142 ++++--- scripts/docs/index.test.mjs | 105 +++++ 69 files changed, 4425 insertions(+), 119 deletions(-) create mode 100644 docs-gen/enriched/Autocomplete.md create mode 100644 docs-gen/enriched/BaseSchema.md create mode 100644 docs-gen/enriched/Blockquote.md create mode 100644 docs-gen/enriched/Bold.md create mode 100644 docs-gen/enriched/Breaks.md create mode 100644 docs-gen/enriched/Checkbox.md create mode 100644 docs-gen/enriched/ClicksOnEdges.md create mode 100644 docs-gen/enriched/Clipboard.md create mode 100644 docs-gen/enriched/Code.md create mode 100644 docs-gen/enriched/CodeBlock.md create mode 100644 docs-gen/enriched/Color.md create mode 100644 docs-gen/enriched/CommandMenu.md create mode 100644 docs-gen/enriched/Cursor.md create mode 100644 docs-gen/enriched/Deflist.md create mode 100644 docs-gen/enriched/EditorModeKeymap.md create mode 100644 docs-gen/enriched/Emoji.md create mode 100644 docs-gen/enriched/FilePaste.md create mode 100644 docs-gen/enriched/FoldingHeading.md create mode 100644 docs-gen/enriched/GPT.md create mode 100644 docs-gen/enriched/Heading.md create mode 100644 docs-gen/enriched/History.md create mode 100644 docs-gen/enriched/HorizontalRule.md create mode 100644 docs-gen/enriched/Html.md create mode 100644 docs-gen/enriched/Image.md create mode 100644 docs-gen/enriched/ImgSize.md create mode 100644 docs-gen/enriched/Italic.md create mode 100644 docs-gen/enriched/Link.md create mode 100644 docs-gen/enriched/Lists.md create mode 100644 docs-gen/enriched/Mark.md create mode 100644 docs-gen/enriched/Math.md create mode 100644 docs-gen/enriched/Mermaid.md create mode 100644 docs-gen/enriched/Monospace.md create mode 100644 docs-gen/enriched/Placeholder.md create mode 100644 docs-gen/enriched/QuoteLink.md create mode 100644 docs-gen/enriched/Resizable.md create mode 100644 docs-gen/enriched/Search.md create mode 100644 docs-gen/enriched/Selection.md create mode 100644 docs-gen/enriched/SelectionContext.md create mode 100644 docs-gen/enriched/Strike.md create mode 100644 docs-gen/enriched/Subscript.md create mode 100644 docs-gen/enriched/Superscript.md create mode 100644 docs-gen/enriched/Table.md create mode 100644 docs-gen/enriched/Underline.md create mode 100644 docs-gen/enriched/Video.md create mode 100644 docs-gen/enriched/WidgetDecoration.md create mode 100644 docs-gen/enriched/YfmConfigs.md create mode 100644 docs-gen/enriched/YfmCut.md create mode 100644 docs-gen/enriched/YfmFile.md create mode 100644 docs-gen/enriched/YfmHeading.md create mode 100644 docs-gen/enriched/YfmHtmlBlock.md create mode 100644 docs-gen/enriched/YfmNote.md create mode 100644 docs-gen/enriched/YfmTable.md create mode 100644 docs-gen/enriched/YfmTabs.md create mode 100644 docs/how-to-generate-extension-docs.md create mode 100644 scripts/docs/assembler.test.mjs create mode 100644 scripts/docs/extractor/regex.test.mjs create mode 100644 scripts/docs/index.test.mjs diff --git a/.gitignore b/.gitignore index 7d58400f..b126f300 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ test-results/ # generated docs docs-src/ docs-dist/ +docs-gen/* +!docs-gen/enriched/ +!docs-gen/enriched/*.md # nx .nx/cache diff --git a/AGENTS.md b/AGENTS.md index 771722b2..d6cadac4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ Project docs live in `docs/`. Read the relevant file before working on the corre |--------------------------|------| | Visual / Playwright tests | [`docs/how-to-add-visual-test.md`](docs/how-to-add-visual-test.md) | | Creating a new extension | [`docs/how-to-create-extension.md`](docs/how-to-create-extension.md) | +| Generating extension docs | [`docs/how-to-generate-extension-docs.md`](docs/how-to-generate-extension-docs.md) | | Adding Markdown text bindings | [`docs/how-to-add-text-binding-extension-in-markdown.md`](docs/how-to-add-text-binding-extension-in-markdown.md) | | Customizing toolbars | [`docs/how-to-customize-toolbars.md`](docs/how-to-customize-toolbars.md) | | Customizing the editor | [`docs/how-to-customize-the-editor.md`](docs/how-to-customize-the-editor.md) | diff --git a/docs-gen/enriched/Autocomplete.md b/docs-gen/enriched/Autocomplete.md new file mode 100644 index 00000000..9b051276 --- /dev/null +++ b/docs-gen/enriched/Autocomplete.md @@ -0,0 +1,29 @@ +--- +extension: Autocomplete +version: 15.40.0 +category: behavior +--- + +# Autocomplete + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/BaseSchema.md b/docs-gen/enriched/BaseSchema.md new file mode 100644 index 00000000..7a8d8b86 --- /dev/null +++ b/docs-gen/enriched/BaseSchema.md @@ -0,0 +1,69 @@ +--- +extension: BaseSchema +version: 15.40.0 +category: base +--- + +# BaseSchema + +This base extension adds 1 editor action to the editor pipeline. It is included in `ZeroPreset`, `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- ZeroPreset +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Actions + +| Action ID | +|-----------| +| `toParagraph` | + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +Serializer patterns: + +- ` \n` +- `\n` + +## Options + +| Option | Type | +|--------|------| +| `paragraphKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +hello! +``` + +```markdown +hello!\n\n \n\nworld! +``` + +```markdown +> hello!\n>\n>  \n> \n> world! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `hello!` renders through this extension's parser/serializer integration. +- `hello!\n\n \n\nworld!` renders through this extension's parser/serializer integration. +- `> hello!\n>\n>  \n> \n> world!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `ZeroPreset`, `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/Blockquote.md b/docs-gen/enriched/Blockquote.md new file mode 100644 index 00000000..52703868 --- /dev/null +++ b/docs-gen/enriched/Blockquote.md @@ -0,0 +1,78 @@ +--- +extension: Blockquote +version: 15.40.0 +category: markdown +--- + +# Blockquote + +This markdown extension adds nodes such as `blockquote`, 1 editor action to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `blockquote` + +## Actions + +| Action ID | +|-----------| +| `quote` | + +## Keymaps + +| Key | +|-----| +| `Backspace` | + +## Input Rules + +| Pattern | +|---------| +| `/^\s*>\s$/` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `qouteKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +> hello! +``` + +```markdown +> > hello! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `> hello!` renders through this extension's parser/serializer integration. +- `> > hello!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `blockquote`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Bold.md b/docs-gen/enriched/Bold.md new file mode 100644 index 00000000..3fef6d9f --- /dev/null +++ b/docs-gen/enriched/Bold.md @@ -0,0 +1,78 @@ +--- +extension: Bold +version: 15.40.0 +category: markdown +--- + +# Bold + +This markdown extension adds marks such as `strong`, 1 editor action to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Mark: `strong` + +## Actions + +| Action ID | +|-----------| +| `bold` | + +## Input Rules + +| Pattern | +|---------| +| `**...**` | +| `__...__` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `boldKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +**hello!** +``` + +```markdown +__hello!__ +``` + +```markdown +he**llo wor**ld! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `**hello!**` renders through this extension's parser/serializer integration. +- `__hello!__` renders through this extension's parser/serializer integration. +- `he**llo wor**ld!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `strong`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Breaks.md b/docs-gen/enriched/Breaks.md new file mode 100644 index 00000000..2f55b351 --- /dev/null +++ b/docs-gen/enriched/Breaks.md @@ -0,0 +1,58 @@ +--- +extension: Breaks +version: 15.40.0 +category: markdown +--- + +# Breaks + +This markdown extension adds nodes such as `hard_break`, `soft_break` to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `hard_break` + +### Node: `soft_break` + +## Keymaps + +| Key | +|-----| +| `Shift-Enter` | +| `Ctrl-Enter` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +Serializer patterns: + +- `\\\n` +- `\n` + +## Options + +| Option | Type | +|--------|------| +| `TODO` | `[context] make this deprecated preferredBreak?: 'hard' | 'soft'` | + +## Syntax Guide + +The exact syntax is inferred from serializer hints: + +- `\\\n` appears in the serializer implementation and documents the expected markup shape. +- `\n` appears in the serializer implementation and documents the expected markup shape. + +## Use Cases + +- Enable it when your editor setup needs nodes `hard_break`, `soft_break`. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/Checkbox.md b/docs-gen/enriched/Checkbox.md new file mode 100644 index 00000000..351d33c7 --- /dev/null +++ b/docs-gen/enriched/Checkbox.md @@ -0,0 +1,93 @@ +--- +extension: Checkbox +version: 15.40.0 +category: yfm +--- + +# Checkbox + +This YFM extension adds nodes such as `checkbox`, `checkbox_input`, `checkbox_label`, 1 editor action, 1 ProseMirror plugin to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `checkbox` + +### Node: `checkbox_input` + +### Node: `checkbox_label` + +## Actions + +| Action ID | +|-----------| +| `addCheckbox` | + +## Input Rules + +| Pattern | +|---------| +| `/^\[(\s?)\]\s$/` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `checkboxPlugin` + +## Markdown Serialization + +Serializer patterns: + +- `[${checked ? ` +- `[ ] ` + +## Plugins + +- `fixPastePlugin` + +## Options + +| Option | Type | +|--------|------| +| `multiline` | `boolean` | + +## Markup Examples + +Extracted from tests: + +```markdown +[ ] checkbox +``` + +```markdown +[X] checkbox +``` + +```markdown +[ ] abobo + +``` + +```markdown +[X] **bold** text +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `[ ] checkbox` renders through this extension's parser/serializer integration. +- `[X] checkbox` renders through this extension's parser/serializer integration. +- `[ ] abobo +` renders through this extension's parser/serializer integration. +- `[X] **bold** text` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `checkbox`, `checkbox_input`, `checkbox_label`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/ClicksOnEdges.md b/docs-gen/enriched/ClicksOnEdges.md new file mode 100644 index 00000000..8baabd3d --- /dev/null +++ b/docs-gen/enriched/ClicksOnEdges.md @@ -0,0 +1,37 @@ +--- +extension: ClicksOnEdges +version: 15.40.0 +category: behavior +--- + +# ClicksOnEdges + +This behavior extension adds 2 editor actions to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Actions + +| Action ID | +|-----------| +| `addEmptyDefaultTextblockToStartOfDocument` | +| `addEmptyDefaultTextblockToEndOfDocument` | + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 2 related editor actions. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Clipboard.md b/docs-gen/enriched/Clipboard.md new file mode 100644 index 00000000..e859c8d4 --- /dev/null +++ b/docs-gen/enriched/Clipboard.md @@ -0,0 +1,34 @@ +--- +extension: Clipboard +version: 15.40.0 +category: behavior +--- + +# Clipboard + +This behavior extension adds 1 ProseMirror plugin to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Plugins + +- `handlePasteIntoCodePlugin` + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/Code.md b/docs-gen/enriched/Code.md new file mode 100644 index 00000000..1c154395 --- /dev/null +++ b/docs-gen/enriched/Code.md @@ -0,0 +1,75 @@ +--- +extension: Code +version: 15.40.0 +category: markdown +--- + +# Code + +This markdown extension adds marks such as `code`, 1 editor action to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Mark: `code` + +## Actions + +| Action ID | +|-----------| +| `code` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `codeKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +`hello!` +``` + +```markdown +he`llo wor`ld! +``` + +```markdown +This is **strong *emphasized text with `code` in* it** +``` + +```markdown +`\\n` +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `\`hello!\`` renders through this extension's parser/serializer integration. +- `he\`llo wor\`ld!` renders through this extension's parser/serializer integration. +- `This is **strong *emphasized text with \`code\` in* it**` renders through this extension's parser/serializer integration. +- `\`\\n\`` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `code`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/CodeBlock.md b/docs-gen/enriched/CodeBlock.md new file mode 100644 index 00000000..e3ab3ecc --- /dev/null +++ b/docs-gen/enriched/CodeBlock.md @@ -0,0 +1,100 @@ +--- +extension: CodeBlock +version: 15.40.0 +category: markdown +--- + +# CodeBlock + +This markdown extension adds nodes such as `code_block`, 1 editor action, 3 ProseMirror plugins to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `code_block` + +## Actions + +| Action ID | +|-----------| +| `toCodeBlock` | + +## Keymaps + +| Key | +|-----| +| `Enter` | +| `Backspace` | +| `Tab` | + +## Input Rules + +| Pattern | +|---------| +| `/^```$/` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +Serializer patterns: + +- `\n` + +## Plugins + +- `codeBlockPastePlugin` +- `codeBlockLineWrappingPlugin` +- `codeBlockLineNumbersPlugin` + +## Options + +| Option | Type | +|--------|------| +| `codeBlockKey` | `string | null` | +| `langs` | `HighlightLangMap` | +| `lineWrapping` | `{ /** * Enable line wrapping toggling in code block * @default false */ enabled?: boolean` | + +## Markup Examples + +Extracted from tests: + +```markdown +Some code:\n\n```\nHere it is\n```\n\nPara +``` + +```markdown +foo\n\n```javascript\n1\n``` +``` + +```markdown +```\nsome code\n\n\n\n``` +``` + +```markdown +~~~\n123\n~~~ +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `Some code:\n\n\`\`\`\nHere it is\n\`\`\`\n\nPara` renders through this extension's parser/serializer integration. +- `foo\n\n\`\`\`javascript\n1\n\`\`\`` renders through this extension's parser/serializer integration. +- `\`\`\`\nsome code\n\n\n\n\`\`\`` renders through this extension's parser/serializer integration. +- `~~~\n123\n~~~` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `code_block`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 3 ProseMirror plugins wired by this extension. diff --git a/docs-gen/enriched/Color.md b/docs-gen/enriched/Color.md new file mode 100644 index 00000000..5ed97f57 --- /dev/null +++ b/docs-gen/enriched/Color.md @@ -0,0 +1,71 @@ +--- +extension: Color +version: 15.40.0 +category: yfm +--- + +# Color + +This YFM extension adds marks such as `color`, 1 editor action to the editor pipeline. It is included in `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- FullPreset + +## Schema + +### Mark: `color` + +## Actions + +| Action ID | +|-----------| +| `colorify` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `color` +- `mdPlugin` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `validateClassNameColorName` | `(colorName: string) => boolean` | +| `parseStyleColorValue` | `(color: string) => string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +{c1}(hello!) +``` + +```markdown +he{c2}(llo wor)ld! +``` + +```markdown +{green}(some\\(){blue}(2,3){green}(\\)) +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `{c1}(hello!)` renders through this extension's parser/serializer integration. +- `he{c2}(llo wor)ld!` renders through this extension's parser/serializer integration. +- `{green}(some\\(){blue}(2,3){green}(\\))` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `color`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/CommandMenu.md b/docs-gen/enriched/CommandMenu.md new file mode 100644 index 00000000..35aa345e --- /dev/null +++ b/docs-gen/enriched/CommandMenu.md @@ -0,0 +1,36 @@ +--- +extension: CommandMenu +version: 15.40.0 +category: behavior +--- + +# CommandMenu + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `actions` | `Config` | +| `nodesIgnoreList` | `readonly string[]` | + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Cursor.md b/docs-gen/enriched/Cursor.md new file mode 100644 index 00000000..fa7b6a01 --- /dev/null +++ b/docs-gen/enriched/Cursor.md @@ -0,0 +1,35 @@ +--- +extension: Cursor +version: 15.40.0 +category: behavior +--- + +# Cursor + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `dropOptions` | `Parameters[0]` | + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Deflist.md b/docs-gen/enriched/Deflist.md new file mode 100644 index 00000000..9be6ddfd --- /dev/null +++ b/docs-gen/enriched/Deflist.md @@ -0,0 +1,54 @@ +--- +extension: Deflist +version: 15.40.0 +category: markdown +--- + +# Deflist + +This markdown extension adds nodes such as `dl`, `dt`, `dd`, 1 editor action to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `dl` + +### Node: `dt` + +### Node: `dd` + +## Actions + +| Action ID | +|-----------| +| `toDefList` | + +## Keymaps + +| Key | +|-----| +| `Enter` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `deflistPlugin` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Enable it when your editor setup needs nodes `dl`, `dt`, `dd`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/EditorModeKeymap.md b/docs-gen/enriched/EditorModeKeymap.md new file mode 100644 index 00000000..30835ac3 --- /dev/null +++ b/docs-gen/enriched/EditorModeKeymap.md @@ -0,0 +1,35 @@ +--- +extension: EditorModeKeymap +version: 15.40.0 +category: behavior +--- + +# EditorModeKeymap + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `ignoreKeysList` | `string[]` | + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Emoji.md b/docs-gen/enriched/Emoji.md new file mode 100644 index 00000000..d73c8bad --- /dev/null +++ b/docs-gen/enriched/Emoji.md @@ -0,0 +1,53 @@ +--- +extension: Emoji +version: 15.40.0 +category: yfm +--- + +# Emoji + +This YFM extension adds nodes such as `emoji`, 1 editor action to the editor pipeline. It is included in `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- FullPreset + +## Schema + +### Node: `emoji` + +## Actions + +| Action ID | +|-----------| +| `openEmojiSuggest` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +Serializer patterns: + +- `:${node.attrs[EmojiConsts.NodeAttrs.Markup]}:` + +## Markup Examples + +Extracted from tests: + +```markdown +I can parse :ddd: emoji +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `I can parse :ddd: emoji` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `emoji`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/FilePaste.md b/docs-gen/enriched/FilePaste.md new file mode 100644 index 00000000..955b5738 --- /dev/null +++ b/docs-gen/enriched/FilePaste.md @@ -0,0 +1,29 @@ +--- +extension: FilePaste +version: 15.40.0 +category: behavior +--- + +# FilePaste + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/FoldingHeading.md b/docs-gen/enriched/FoldingHeading.md new file mode 100644 index 00000000..29b88954 --- /dev/null +++ b/docs-gen/enriched/FoldingHeading.md @@ -0,0 +1,50 @@ +--- +extension: FoldingHeading +version: 15.40.0 +category: additional +--- + +# FoldingHeading + +This additional extension adds 1 editor action, 1 ProseMirror plugin to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Actions + +| Action ID | +|-----------| +| `toggleHeadingFolding` | + +## Keymaps + +| Key | +|-----| +| `Enter` | +| `Backspace` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `transform` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Plugins + +- `foldingPlugin` + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/GPT.md b/docs-gen/enriched/GPT.md new file mode 100644 index 00000000..00670560 --- /dev/null +++ b/docs-gen/enriched/GPT.md @@ -0,0 +1,36 @@ +--- +extension: GPT +version: 15.40.0 +category: additional +--- + +# GPT + +This additional extension adds 1 editor action to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Actions + +| Action ID | +|-----------| +| `addGptWidget` | + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Heading.md b/docs-gen/enriched/Heading.md new file mode 100644 index 00000000..af9794ee --- /dev/null +++ b/docs-gen/enriched/Heading.md @@ -0,0 +1,103 @@ +--- +extension: Heading +version: 15.40.0 +category: markdown +--- + +# Heading + +This markdown extension adds nodes such as `heading`, 6 editor actions to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `heading` + +## Actions + +| Action ID | +|-----------| +| `toH1` | +| `toH2` | +| `toH3` | +| `toH4` | +| `toH5` | +| `toH6` | + +## Keymaps + +| Key | +|-----| +| `Backspace` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `h1Key` | `string | null` | +| `h2Key` | `string | null` | +| `h3Key` | `string | null` | +| `h4Key` | `string | null` | +| `h5Key` | `string | null` | +| `h6Key` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +# one +``` + +```markdown +## two +``` + +```markdown +### three +``` + +```markdown +#### four +``` + +```markdown +##### five +``` + +```markdown +###### six +``` + +```markdown +## heading with **bold** +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `# one` renders through this extension's parser/serializer integration. +- `## two` renders through this extension's parser/serializer integration. +- `### three` renders through this extension's parser/serializer integration. +- `#### four` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `heading`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 6 related editor actions. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/History.md b/docs-gen/enriched/History.md new file mode 100644 index 00000000..543b0726 --- /dev/null +++ b/docs-gen/enriched/History.md @@ -0,0 +1,45 @@ +--- +extension: History +version: 15.40.0 +category: behavior +--- + +# History + +This behavior extension adds 2 editor actions to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Actions + +| Action ID | +|-----------| +| `undo` | +| `redo` | + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `config` | `Parameters[0]` | +| `undoKey` | `string | null` | +| `redoKey` | `string | null` | + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 2 related editor actions. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/HorizontalRule.md b/docs-gen/enriched/HorizontalRule.md new file mode 100644 index 00000000..58edf35f --- /dev/null +++ b/docs-gen/enriched/HorizontalRule.md @@ -0,0 +1,71 @@ +--- +extension: HorizontalRule +version: 15.40.0 +category: markdown +--- + +# HorizontalRule + +This markdown extension adds nodes such as `horizontal_rule`, 1 editor action to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `horizontal_rule` + +## Actions + +| Action ID | +|-----------| +| `hRule` | + +## Input Rules + +| Pattern | +|---------| +| `/^(---|___|\*\*\*)$/` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +--- +``` + +```markdown +___ +``` + +```markdown +*** +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `---` renders through this extension's parser/serializer integration. +- `___` renders through this extension's parser/serializer integration. +- `***` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `horizontal_rule`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Html.md b/docs-gen/enriched/Html.md new file mode 100644 index 00000000..65863c3d --- /dev/null +++ b/docs-gen/enriched/Html.md @@ -0,0 +1,54 @@ +--- +extension: Html +version: 15.40.0 +category: markdown +--- + +# Html + +This markdown extension adds nodes such as `html_block`, `html_inline` to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `html_block` + +### Node: `html_inline` + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +This is inline html +``` + +```markdown +
This is block html with inline tags
+``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `This is inline html` renders through this extension's parser/serializer integration. +- `
This is block html with inline tags
` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `html_block`, `html_inline`. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/Image.md b/docs-gen/enriched/Image.md new file mode 100644 index 00000000..9c24488f --- /dev/null +++ b/docs-gen/enriched/Image.md @@ -0,0 +1,59 @@ +--- +extension: Image +version: 15.40.0 +category: markdown +--- + +# Image + +This markdown extension adds nodes such as `image`, 1 editor action to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `image` + +## Actions + +| Action ID | +|-----------| +| `addImage` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +![](img.png) +``` + +```markdown +![alt text](img2.png "title text") +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `![](img.png)` renders through this extension's parser/serializer integration. +- `![alt text](img2.png "title text")` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `image`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/ImgSize.md b/docs-gen/enriched/ImgSize.md new file mode 100644 index 00000000..5cf81d5f --- /dev/null +++ b/docs-gen/enriched/ImgSize.md @@ -0,0 +1,76 @@ +--- +extension: ImgSize +version: 15.40.0 +category: yfm +--- + +# ImgSize + +This YFM extension adds 2 editor actions, 1 ProseMirror plugin to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Actions + +| Action ID | +|-----------| +| `addImageAction` | +| `addImageWidget` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `imsize` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Plugins + +- `imgSizeNodeViewPlugin` + +## Options + +| Option | Type | +|--------|------| +| `needToSetDimensionsForUploadedImages` | `boolean` | + +## Markup Examples + +Extracted from tests: + +```markdown +![](img.png) +``` + +```markdown +![alt text](img2.png "title text") +``` + +```markdown +![](img3.png =200x100) +``` + +```markdown +![alt text 2](img4.png "title text 2" =400x300) +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `![](img.png)` renders through this extension's parser/serializer integration. +- `![alt text](img2.png "title text")` renders through this extension's parser/serializer integration. +- `![](img3.png =200x100)` renders through this extension's parser/serializer integration. +- `![alt text 2](img4.png "title text 2" =400x300)` renders through this extension's parser/serializer integration. + +## Use Cases + +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 2 related editor actions. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/Italic.md b/docs-gen/enriched/Italic.md new file mode 100644 index 00000000..e42c55e4 --- /dev/null +++ b/docs-gen/enriched/Italic.md @@ -0,0 +1,78 @@ +--- +extension: Italic +version: 15.40.0 +category: markdown +--- + +# Italic + +This markdown extension adds marks such as `em`, 1 editor action to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Mark: `em` + +## Actions + +| Action ID | +|-----------| +| `italic` | + +## Input Rules + +| Pattern | +|---------| +| `*...*` | +| `_..._` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `italicKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +*hello!* +``` + +```markdown +_hello!_ +``` + +```markdown +he*llo wor*ld! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `*hello!*` renders through this extension's parser/serializer integration. +- `_hello!_` renders through this extension's parser/serializer integration. +- `he*llo wor*ld!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `em`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Link.md b/docs-gen/enriched/Link.md new file mode 100644 index 00000000..15590804 --- /dev/null +++ b/docs-gen/enriched/Link.md @@ -0,0 +1,94 @@ +--- +extension: Link +version: 15.40.0 +category: markdown +--- + +# Link + +This markdown extension adds marks such as `link`, 2 editor actions, 2 ProseMirror plugins to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Mark: `link` + +## Actions + +| Action ID | +|-----------| +| `addLink` | +| `link` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Plugins + +- `linkTooltipPlugin` +- `linkPasteEnhance` + +## Options + +| Option | Type | +|--------|------| +| `linkKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +[yandex](ya.ru) +``` + +```markdown +[imageboard](4chan.org "4chan") +``` + +```markdown +[text](https://example.com/+_file/#~anchor) +``` + +```markdown + +``` + +```markdown +[parentheses](https://example.com/example=?qwe\\(asd) +``` + +```markdown +[parentheses2](https://example.com/example=?qwe\\(asd\\)\\)) +``` + +```markdown +[test text](http://example.com?) +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `[yandex](ya.ru)` renders through this extension's parser/serializer integration. +- `[imageboard](4chan.org "4chan")` renders through this extension's parser/serializer integration. +- `[text](https://example.com/+_file/#~anchor)` renders through this extension's parser/serializer integration. +- `` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `link`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 2 related editor actions. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 2 ProseMirror plugins wired by this extension. diff --git a/docs-gen/enriched/Lists.md b/docs-gen/enriched/Lists.md new file mode 100644 index 00000000..35f126b8 --- /dev/null +++ b/docs-gen/enriched/Lists.md @@ -0,0 +1,119 @@ +--- +extension: Lists +version: 15.40.0 +category: markdown +--- + +# Lists + +This markdown extension adds nodes such as `list_item`, `bullet_list`, `ordered_list`, 4 editor actions, 2 ProseMirror plugins to the editor pipeline. It is included in `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- CommonMarkPreset +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `list_item` + +### Node: `bullet_list` + +### Node: `ordered_list` + +## Actions + +| Action ID | +|-----------| +| `toBulletList` | +| `toOrderedList` | +| `sinkListItem` | +| `liftListItem` | + +## Keymaps + +| Key | +|-----| +| `Tab` | +| `Shift-Tab` | +| `Backspace` | +| `Mod-[` | +| `Mod-]` | +| `Enter` | + +## Input Rules + +| Pattern | +|---------| +| `/^(\d+)([.)])\s$/` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Plugins + +- `mergeListsPlugin` +- `collapseListsPlugin` + +## Options + +| Option | Type | +|--------|------| +| `ulKey` | `string | null` | +| `olKey` | `string | null` | +| `ulInputRules` | `ListsInputRulesOptions['bulletListInputRule']` | + +## Markup Examples + +Extracted from tests: + +```markdown +* one\n* two +``` + +```markdown +* one\n\n* two +``` + +```markdown +1. one\n2. two +``` + +```markdown +1. one\n\n2. two +``` + +```markdown +1) one\n2) two +``` + +```markdown +1) one\n\n2) two +``` + +```markdown +- + * 2. item +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `* one\n* two` renders through this extension's parser/serializer integration. +- `* one\n\n* two` renders through this extension's parser/serializer integration. +- `1. one\n2. two` renders through this extension's parser/serializer integration. +- `1. one\n\n2. two` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `list_item`, `bullet_list`, `ordered_list`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 4 related editor actions. +- Keep it aligned with `CommonMarkPreset`, `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 2 ProseMirror plugins wired by this extension. diff --git a/docs-gen/enriched/Mark.md b/docs-gen/enriched/Mark.md new file mode 100644 index 00000000..fec401ab --- /dev/null +++ b/docs-gen/enriched/Mark.md @@ -0,0 +1,65 @@ +--- +extension: Mark +version: 15.40.0 +category: markdown +--- + +# Mark + +This markdown extension adds marks such as `mark`, 1 editor action to the editor pipeline. It is included in `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- FullPreset + +## Schema + +### Mark: `mark` + +## Actions + +| Action ID | +|-----------| +| `mark` | + +## Input Rules + +| Pattern | +|---------| +| `==...==` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `markPlugin` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +==hello!== +``` + +```markdown +he==llo wor==ld! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `==hello!==` renders through this extension's parser/serializer integration. +- `he==llo wor==ld!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `mark`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Math.md b/docs-gen/enriched/Math.md new file mode 100644 index 00000000..94e144e9 --- /dev/null +++ b/docs-gen/enriched/Math.md @@ -0,0 +1,140 @@ +--- +extension: Math +version: 15.40.0 +category: additional +--- + +# Math + +This additional extension adds 2 editor actions, 1 ProseMirror plugin to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +Not included in any standard preset (use directly). + +## Actions + +| Action ID | +|-----------| +| `addMathInline` | +| `toMathBlock` | + +## Keymaps + +| Key | +|-----| +| `Enter` | + +## Input Rules + +| Pattern | +|---------| +| `/^\$\$\s$/` | +| `/\$[^$\s]+\$$/` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `transform` + +## Markdown Serialization + +Serializer patterns: + +- `$${node.textContent}$` +- `$$${node.textContent}$$\n\n` + +## Plugins + +- `latexPastePlugin` + +## Markup Examples + +Extracted from tests: + +```markdown +Inline math: $\\sqrt{3x-1}+(1+x)^2$ +``` + +```markdown +$$${formula}$$\n\n +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `Inline math: $\\sqrt{3x-1}+(1+x)^2--- +extension: Math +version: 15.40.0 +category: additional +--- + +# Math + +This additional extension adds 2 editor actions, 1 ProseMirror plugin to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +Not included in any standard preset (use directly). + +## Actions + +| Action ID | +|-----------| +| `addMathInline` | +| `toMathBlock` | + +## Keymaps + +| Key | +|-----| +| `Enter` | + +## Input Rules + +| Pattern | +|---------| +| `/^\$\$\s$/` | +| `/\$[^$\s]+\$$/` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `transform` + +## Markdown Serialization + +Serializer patterns: + +- `$${node.textContent}$` +- `$$${node.textContent}$$\n\n` + +## Plugins + +- `latexPastePlugin` + +## Markup Examples + +Extracted from tests: + +```markdown +Inline math: $\\sqrt{3x-1}+(1+x)^2$ +``` + +```markdown +$$${formula}$$\n\n +``` + +## Syntax Guide + + renders through this extension's parser/serializer integration. +- `$${formula}$\n\n` renders through this extension's parser/serializer integration. + +## Use Cases + +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 2 related editor actions. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/Mermaid.md b/docs-gen/enriched/Mermaid.md new file mode 100644 index 00000000..248cb799 --- /dev/null +++ b/docs-gen/enriched/Mermaid.md @@ -0,0 +1,61 @@ +--- +extension: Mermaid +version: 15.40.0 +category: additional +--- + +# Mermaid + +This additional extension adds nodes such as `mermaid`, 1 editor action to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +Not included in any standard preset (use directly). + +## Schema + +### Node: `mermaid` + +## Actions + +| Action ID | +|-----------| +| `createMermaid` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `transform` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `loadRuntimeScript` | `() => void` | +| `autoSave` | `{ enabled: boolean` | +| `delay` | `number` | + +## Markup Examples + +Extracted from tests: + +```markdown +```mermaid\ncontent\n```\n +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `\`\`\`mermaid\ncontent\n\`\`\`\n` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `mermaid`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Monospace.md b/docs-gen/enriched/Monospace.md new file mode 100644 index 00000000..9f03f0ed --- /dev/null +++ b/docs-gen/enriched/Monospace.md @@ -0,0 +1,66 @@ +--- +extension: Monospace +version: 15.40.0 +category: yfm +--- + +# Monospace + +This YFM extension adds marks such as `monospace`, 1 editor action to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Mark: `monospace` + +## Actions + +| Action ID | +|-----------| +| `mono` | + +## Input Rules + +| Pattern | +|---------| +| `##...##` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `yfmPlugin` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +##hello!## +``` + +```markdown +he##llo wor##ld! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `##hello!##` renders through this extension's parser/serializer integration. +- `he##llo wor##ld!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `monospace`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Placeholder.md b/docs-gen/enriched/Placeholder.md new file mode 100644 index 00000000..662eb488 --- /dev/null +++ b/docs-gen/enriched/Placeholder.md @@ -0,0 +1,29 @@ +--- +extension: Placeholder +version: 15.40.0 +category: behavior +--- + +# Placeholder + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/QuoteLink.md b/docs-gen/enriched/QuoteLink.md new file mode 100644 index 00000000..04ce88d4 --- /dev/null +++ b/docs-gen/enriched/QuoteLink.md @@ -0,0 +1,57 @@ +--- +extension: QuoteLink +version: 15.40.0 +category: additional +--- + +# QuoteLink + +This additional extension adds nodes such as `yfm_quote-link`, 2 editor actions to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Schema + +### Node: `yfm_quote-link` + +## Actions + +| Action ID | +|-----------| +| `quoteLink` | +| `addLinkToQuoteLink` | + +## Input Rules + +| Pattern | +|---------| +| `/^\s*>\s$/` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +Serializer patterns: + +- `[${node.attrs[QuoteLinkAttr.DataContent]}](${ + node.attrs[QuoteLinkAttr.Cite] + }){data-quotelink=true}` +- `\n` +- `\n` + +## Syntax Guide + +This extension reacts to these editor input patterns: + +- `/^\s*>\s$/` is registered as an input rule for this extension. + +## Use Cases + +- Enable it when your editor setup needs nodes `yfm_quote-link`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 2 related editor actions. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Resizable.md b/docs-gen/enriched/Resizable.md new file mode 100644 index 00000000..30a0ef56 --- /dev/null +++ b/docs-gen/enriched/Resizable.md @@ -0,0 +1,29 @@ +--- +extension: Resizable +version: 15.40.0 +category: behavior +--- + +# Resizable + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Search.md b/docs-gen/enriched/Search.md new file mode 100644 index 00000000..45a79860 --- /dev/null +++ b/docs-gen/enriched/Search.md @@ -0,0 +1,29 @@ +--- +extension: Search +version: 15.40.0 +category: behavior +--- + +# Search + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Selection.md b/docs-gen/enriched/Selection.md new file mode 100644 index 00000000..3f50b123 --- /dev/null +++ b/docs-gen/enriched/Selection.md @@ -0,0 +1,34 @@ +--- +extension: Selection +version: 15.40.0 +category: behavior +--- + +# Selection + +This behavior extension adds 1 ProseMirror plugin to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Plugins + +- `selection` + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/SelectionContext.md b/docs-gen/enriched/SelectionContext.md new file mode 100644 index 00000000..c087eb13 --- /dev/null +++ b/docs-gen/enriched/SelectionContext.md @@ -0,0 +1,37 @@ +--- +extension: SelectionContext +version: 15.40.0 +category: behavior +--- + +# SelectionContext + +This behavior extension extends editor behavior without introducing new schema elements. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `config` | `ContextConfig` | +| `placement` | `'top' | 'bottom'` | +| `flip` | `boolean` | + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/Strike.md b/docs-gen/enriched/Strike.md new file mode 100644 index 00000000..2391607c --- /dev/null +++ b/docs-gen/enriched/Strike.md @@ -0,0 +1,71 @@ +--- +extension: Strike +version: 15.40.0 +category: markdown +--- + +# Strike + +This markdown extension adds marks such as `strike`, 1 editor action to the editor pipeline. It is included in `DefaultPreset`, `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Mark: `strike` + +## Actions + +| Action ID | +|-----------| +| `strike` | + +## Input Rules + +| Pattern | +|---------| +| `~~...~~` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `strikeKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +~~hello!~~ +``` + +```markdown +he~~llo wor~~ld! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `~~hello!~~` renders through this extension's parser/serializer integration. +- `he~~llo wor~~ld!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `strike`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Subscript.md b/docs-gen/enriched/Subscript.md new file mode 100644 index 00000000..922e32fd --- /dev/null +++ b/docs-gen/enriched/Subscript.md @@ -0,0 +1,71 @@ +--- +extension: Subscript +version: 15.40.0 +category: markdown +--- + +# Subscript + +This markdown extension adds marks such as `sub`, 1 editor action to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Mark: `sub` + +## Actions + +| Action ID | +|-----------| +| `subscript` | + +## Input Rules + +| Pattern | +|---------| +| `~...~` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `subPlugin` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +~hello~ +``` + +```markdown +hello~world~! +``` + +```markdown +Ok, hello~w\\ o\\ r\\ l\\ d~! This world is beautiful! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `~hello~` renders through this extension's parser/serializer integration. +- `hello~world~!` renders through this extension's parser/serializer integration. +- `Ok, hello~w\\ o\\ r\\ l\\ d~! This world is beautiful!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `sub`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Superscript.md b/docs-gen/enriched/Superscript.md new file mode 100644 index 00000000..01380a97 --- /dev/null +++ b/docs-gen/enriched/Superscript.md @@ -0,0 +1,71 @@ +--- +extension: Superscript +version: 15.40.0 +category: markdown +--- + +# Superscript + +This markdown extension adds marks such as `sup`, 1 editor action to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Mark: `sup` + +## Actions + +| Action ID | +|-----------| +| `supscript` | + +## Input Rules + +| Pattern | +|---------| +| `^...^` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `sup` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +^hello^ +``` + +```markdown +hello^world^! +``` + +```markdown +Ok, hello^w\\ o\\ r\\ l\\ d^! This world is beautiful! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `^hello^` renders through this extension's parser/serializer integration. +- `hello^world^!` renders through this extension's parser/serializer integration. +- `Ok, hello^w\\ o\\ r\\ l\\ d^! This world is beautiful!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `sup`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Table.md b/docs-gen/enriched/Table.md new file mode 100644 index 00000000..726aef32 --- /dev/null +++ b/docs-gen/enriched/Table.md @@ -0,0 +1,83 @@ +--- +extension: Table +version: 15.40.0 +category: markdown +--- + +# Table + +This markdown extension adds nodes such as `table`, `thead`, `tbody`, `tr`, 2 editor actions, 1 ProseMirror plugin to the editor pipeline. It is included in `DefaultPreset`, `YfmPreset`, `FullPreset`. + +## Presets + +- DefaultPreset +- YfmPreset +- FullPreset + +## Schema + +### Node: `table` + +### Node: `thead` + +### Node: `tbody` + +### Node: `tr` + +### Node: `th` + +### Node: `td` + +## Actions + +| Action ID | +|-----------| +| `createTable` | +| `deleteTable` | + +## Keymaps + +| Key | +|-----| +| `Tab` | +| `Shift-Tab` | +| `Enter` | +| `Shift-Enter` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +Serializer patterns: + +- `\n` +- `\|:---` +- `\|:---:` +- `\|---:` +- `\|---` +- `\|` +- `\|` +- `\|` +- `\|` + +## Plugins + +- `tableCellContextPlugin` + +## Syntax Guide + +The exact syntax is inferred from serializer hints: + +- `\n` appears in the serializer implementation and documents the expected markup shape. +- `|:---` appears in the serializer implementation and documents the expected markup shape. +- `|:---:` appears in the serializer implementation and documents the expected markup shape. +- `|---:` appears in the serializer implementation and documents the expected markup shape. + +## Use Cases + +- Enable it when your editor setup needs nodes `table`, `thead`, `tbody`, `tr`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 2 related editor actions. +- Keep it aligned with `DefaultPreset`, `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/Underline.md b/docs-gen/enriched/Underline.md new file mode 100644 index 00000000..11dfec6e --- /dev/null +++ b/docs-gen/enriched/Underline.md @@ -0,0 +1,72 @@ +--- +extension: Underline +version: 15.40.0 +category: markdown +--- + +# Underline + +This markdown extension adds marks such as `ins`, 1 editor action to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Mark: `ins` + +## Actions + +| Action ID | +|-----------| +| `underline` | + +## Input Rules + +| Pattern | +|---------| +| `++...++` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `insPlugin` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `underlineKey` | `string | null` | + +## Markup Examples + +Extracted from tests: + +```markdown +++hello!++ +``` + +```markdown +he++llo wor++ld! +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `++hello!++` renders through this extension's parser/serializer integration. +- `he++llo wor++ld!` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs marks `ins`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Add it when editor typing rules should transform input into the corresponding markup structure. diff --git a/docs-gen/enriched/Video.md b/docs-gen/enriched/Video.md new file mode 100644 index 00000000..59274050 --- /dev/null +++ b/docs-gen/enriched/Video.md @@ -0,0 +1,77 @@ +--- +extension: Video +version: 15.40.0 +category: yfm +--- + +# Video + +This YFM extension adds nodes such as `video`, 1 editor action to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `video` + +## Actions + +| Action ID | +|-----------| +| `video` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `videoPlugin` + +## Markdown Serialization + +This extension does not produce markdown output. + +## Markup Examples + +Extracted from tests: + +```markdown +YouTube @[youtube](dQw4w9WgXcQ) +``` + +```markdown +Vimeo @[vimeo](19706846) +``` + +```markdown +Vine @[vine](etVpwB7uHlw) +``` + +```markdown +Prezi @[prezi](1kkxdtlp4241) +``` + +```markdown +Osf @[osf](kuvg9) +``` + +```markdown +YouTube @[youtube](yt-video-1) +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `YouTube @[youtube](dQw4w9WgXcQ)` renders through this extension's parser/serializer integration. +- `Vimeo @[vimeo](19706846)` renders through this extension's parser/serializer integration. +- `Vine @[vine](etVpwB7uHlw)` renders through this extension's parser/serializer integration. +- `Prezi @[prezi](1kkxdtlp4241)` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `video`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/WidgetDecoration.md b/docs-gen/enriched/WidgetDecoration.md new file mode 100644 index 00000000..55ccc7d5 --- /dev/null +++ b/docs-gen/enriched/WidgetDecoration.md @@ -0,0 +1,34 @@ +--- +extension: WidgetDecoration +version: 15.40.0 +category: behavior +--- + +# WidgetDecoration + +This behavior extension adds 1 ProseMirror plugin to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. + +## Presets + +Not included in any standard preset (use directly). + +## Markdown Parsing + +No markdown parsing. + +## Markdown Serialization + +This extension does not produce markdown output. + +## Plugins + +- `WidgetDecorationPlugin` + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/YfmConfigs.md b/docs-gen/enriched/YfmConfigs.md new file mode 100644 index 00000000..1f6f2362 --- /dev/null +++ b/docs-gen/enriched/YfmConfigs.md @@ -0,0 +1,42 @@ +--- +extension: YfmConfigs +version: 15.40.0 +category: yfm +--- + +# YfmConfigs + +This YFM extension adds nodes such as `__yfm_lint` to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `__yfm_lint` + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +This extension does not produce markdown output. + +## Options + +| Option | Type | +|--------|------| +| `mods` | `YfmMods` | +| `mix` | `string` | + +## Syntax Guide + +This extension does not define custom markdown syntax. + +## Use Cases + +- Enable it when your editor setup needs nodes `__yfm_lint`. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/YfmCut.md b/docs-gen/enriched/YfmCut.md new file mode 100644 index 00000000..173f0a55 --- /dev/null +++ b/docs-gen/enriched/YfmCut.md @@ -0,0 +1,80 @@ +--- +extension: YfmCut +version: 15.40.0 +category: yfm +--- + +# YfmCut + +This YFM extension adds nodes such as `yfm_cut`, `yfm_cut_title`, `yfm_cut_content`, 1 editor action, 2 ProseMirror plugins to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `yfm_cut` + +### Node: `yfm_cut_title` + +### Node: `yfm_cut_content` + +## Actions + +| Action ID | +|-----------| +| `toYfmCut` | + +## Keymaps + +| Key | +|-----| +| `Backspace` | +| `Enter` | + +## Input Rules + +| Pattern | +|---------| +| `/(?:^)({% cut)\s$/` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `yfmCut` + +## Markdown Serialization + +Serializer patterns: + +- `:::cut [` +- `]` +- `{% cut ` +- `\n` + +## Plugins + +- `cutActivePlugin` +- `cutAutoOpenPlugin` + +## Options + +| Option | Type | +|--------|------| +| `yfmCutKey` | `string | null` | + +## Syntax Guide + +This extension reacts to these editor input patterns: + +- `/(?:^)({% cut)\s$/` is registered as an input rule for this extension. + +## Use Cases + +- Enable it when your editor setup needs nodes `yfm_cut`, `yfm_cut_title`, `yfm_cut_content`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 2 ProseMirror plugins wired by this extension. diff --git a/docs-gen/enriched/YfmFile.md b/docs-gen/enriched/YfmFile.md new file mode 100644 index 00000000..0b93a9e6 --- /dev/null +++ b/docs-gen/enriched/YfmFile.md @@ -0,0 +1,82 @@ +--- +extension: YfmFile +version: 15.40.0 +category: yfm +--- + +# YfmFile + +This YFM extension adds nodes such as `yfmFileNodeName`, 1 editor action to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `yfmFileNodeName` + +## Actions + +| Action ID | +|-----------| +| `addFile` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `fileTransform` + +## Markdown Serialization + +Serializer patterns: + +- `${FILE_MARKUP_PREFIX}${attrsStr} %}` + +## Options + +| Option | Type | +|--------|------| +| `fileUploadHandler` | `FileUploadHandler` | +| `needToSetDimensionsForUploadedImages` | `boolean` | + +## Markup Examples + +Extracted from tests: + +```markdown +{% file src="path/to/readme" name="readme.md" %} +``` + +```markdown +This is file: {% file src="path/to/readme" name="readme.md" %} +``` + +```markdown +{% file src="path/to/readme" name="readme.md" %} - download it +``` + +```markdown +This is file: {% file src="path/to/readme" name="readme.md" %} - download it +``` + +```markdown +{% file src="path/to/readme" name="readme.md" lang="ru" referrerpolicy="origin" rel="help" target="_top" type="text/markdown" %} +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `{% file src="path/to/readme" name="readme.md" %}` renders through this extension's parser/serializer integration. +- `This is file: {% file src="path/to/readme" name="readme.md" %}` renders through this extension's parser/serializer integration. +- `{% file src="path/to/readme" name="readme.md" %} - download it` renders through this extension's parser/serializer integration. +- `This is file: {% file src="path/to/readme" name="readme.md" %} - download it` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `yfmFileNodeName`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/YfmHeading.md b/docs-gen/enriched/YfmHeading.md new file mode 100644 index 00000000..f2b3e698 --- /dev/null +++ b/docs-gen/enriched/YfmHeading.md @@ -0,0 +1,71 @@ +--- +extension: YfmHeading +version: 15.40.0 +category: yfm +--- + +# YfmHeading + +This YFM extension extends editor behavior without introducing new schema elements. It is included in `YfmPreset`, `FullPreset`. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +- YfmPreset +- FullPreset + +## Markdown Parsing + +Uses markdown-it plugins: + +- `headingIdsPlugin` + +## Markdown Serialization + +Serializer patterns: + +- ` {#${anchor}}` + +## Markup Examples + +Extracted from tests: + +```markdown +# one +``` + +```markdown +## two +``` + +```markdown +### three +``` + +```markdown +#### four +``` + +```markdown +##### five +``` + +```markdown +###### six +``` + +```markdown +## heading with **bold** {#heading-with-bold} +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `# one` renders through this extension's parser/serializer integration. +- `## two` renders through this extension's parser/serializer integration. +- `### three` renders through this extension's parser/serializer integration. +- `#### four` renders through this extension's parser/serializer integration. + +## Use Cases + +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. diff --git a/docs-gen/enriched/YfmHtmlBlock.md b/docs-gen/enriched/YfmHtmlBlock.md new file mode 100644 index 00000000..c6bf2bdf --- /dev/null +++ b/docs-gen/enriched/YfmHtmlBlock.md @@ -0,0 +1,57 @@ +--- +extension: YfmHtmlBlock +version: 15.40.0 +category: additional +--- + +# YfmHtmlBlock + +This additional extension adds nodes such as `yfm_html_block`, 1 editor action to the editor pipeline. It is intended to be composed directly when you need this behavior outside the standard presets. The extracted test markup shows how the feature is expected to appear in real content. + +## Presets + +Not included in any standard preset (use directly). + +## Schema + +### Node: `yfm_html_block` + +## Actions + +| Action ID | +|-----------| +| `createYfmHtmlBlock` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `transform` + +## Markdown Serialization + +Serializer patterns: + +- `::: html` +- `\n` +- `:::` + +## Markup Examples + +Extracted from tests: + +```markdown +::: html\ncontent\n::: +``` + +## Syntax Guide + +This extension handles the following markup patterns: + +- `::: html\ncontent\n:::` renders through this extension's parser/serializer integration. + +## Use Cases + +- Enable it when your editor setup needs nodes `yfm_html_block`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Compose it directly in custom presets when you need this feature without pulling in a larger preset. diff --git a/docs-gen/enriched/YfmNote.md b/docs-gen/enriched/YfmNote.md new file mode 100644 index 00000000..87cbb547 --- /dev/null +++ b/docs-gen/enriched/YfmNote.md @@ -0,0 +1,79 @@ +--- +extension: YfmNote +version: 15.40.0 +category: yfm +--- + +# YfmNote + +This YFM extension adds nodes such as `yfm_note`, `yfm_note_title`, `yfm_note_content`, 1 editor action, 1 ProseMirror plugin to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `yfm_note` + +### Node: `yfm_note_title` + +### Node: `yfm_note_content` + +## Actions + +| Action ID | +|-----------| +| `toYfmNote` | + +## Keymaps + +| Key | +|-----| +| `Enter` | +| `Backspace` | + +## Input Rules + +| Pattern | +|---------| +| `/(?:^)({% note)\s$/` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `yfmPlugin` + +## Markdown Serialization + +Serializer patterns: + +- `{% endnote %}` +- `{% note ${parent.attrs[NoteAttrs.Type]} ` +- ` %}\n` +- `\n` + +## Plugins + +- `yfmNoteTooltipPlugin` + +## Options + +| Option | Type | +|--------|------| +| `yfmNoteKey` | `string | null` | + +## Syntax Guide + +This extension reacts to these editor input patterns: + +- `/(?:^)({% note)\s$/` is registered as an input rule for this extension. + +## Use Cases + +- Enable it when your editor setup needs nodes `yfm_note`, `yfm_note_title`, `yfm_note_content`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs-gen/enriched/YfmTable.md b/docs-gen/enriched/YfmTable.md new file mode 100644 index 00000000..ea11b4b4 --- /dev/null +++ b/docs-gen/enriched/YfmTable.md @@ -0,0 +1,95 @@ +--- +extension: YfmTable +version: 15.40.0 +category: yfm +--- + +# YfmTable + +This YFM extension adds nodes such as `yfm_table`, `yfm_tbody`, `yfm_tr`, `yfm_td`, 1 editor action, 2 ProseMirror plugins to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `yfm_table` + +### Node: `yfm_tbody` + +### Node: `yfm_tr` + +### Node: `yfm_td` + +## Actions + +| Action ID | +|-----------| +| `createYfmTable` | + +## Keymaps + +| Key | +|-----| +| `Tab` | +| `Shift-Tab` | +| `ArrowDown` | +| `ArrowUp` | +| `Backspace` | + +## Markdown Parsing + +Uses markdown-it plugins: + +- `yfmTable` + +## Markdown Serialization + +Serializer patterns: + +- `#\|` +- `\|#` +- `\n` +- `\|\|` +- `\n` +- `\|` +- `\n` +- `{.${td.attrs[YfmTableAttr.CellAlign]}}` +- `\|>` +- `\|^` +- `\|\|` +- `\|\|` +- `\n` +- `\|\|` +- `\|` +- `\n` + +## Plugins + +- `yfmTableTransformPastedPlugin` +- `yfmTableControlsPlugins` + +## Options + +| Option | Type | +|--------|------| +| `controls` | `boolean` | +| `dnd` | `boolean` | + +## Syntax Guide + +The exact syntax is inferred from serializer hints: + +- `#|` appears in the serializer implementation and documents the expected markup shape. +- `|#` appears in the serializer implementation and documents the expected markup shape. +- `\n` appears in the serializer implementation and documents the expected markup shape. +- `||` appears in the serializer implementation and documents the expected markup shape. + +## Use Cases + +- Enable it when your editor setup needs nodes `yfm_table`, `yfm_tbody`, `yfm_tr`, `yfm_td`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 2 ProseMirror plugins wired by this extension. diff --git a/docs-gen/enriched/YfmTabs.md b/docs-gen/enriched/YfmTabs.md new file mode 100644 index 00000000..feda7ebb --- /dev/null +++ b/docs-gen/enriched/YfmTabs.md @@ -0,0 +1,101 @@ +--- +extension: YfmTabs +version: 15.40.0 +category: yfm +--- + +# YfmTabs + +This YFM extension adds nodes such as `yfm_radio_tabs`, `yfm_tab`, `yfm_tabs_list`, `yfm_tab_panel`, 1 editor action, 1 ProseMirror plugin to the editor pipeline. It is included in `YfmPreset`, `FullPreset`. + +## Presets + +- YfmPreset +- FullPreset + +## Schema + +### Node: `yfm_radio_tabs` + +### Node: `yfm_tab` + +### Node: `yfm_tabs_list` + +### Node: `yfm_tab_panel` + +### Node: `yfm_tabs` + +### Node: `yfm_radio_tab` + +### Node: `yfm_radio_tab_input` + +### Node: `yfm_radio_tab_label` + +## Actions + +| Action ID | +|-----------| +| `toYfmTabs` | + +## Keymaps + +| Key | +|-----| +| `Backspace` | +| `ArrowDown` | +| `Enter` | +| `Shift-Enter` | + +## Markdown Parsing + +Uses built-in markdown-it tokens (CommonMark). + +## Markdown Serialization + +Serializer patterns: + +- `{% list tabs %}` +- `\n` +- `\n` +- `- ` +- `\n` +- `\n` +- `{% endlist %}` +- `{% list tabs radio %}` +- `\n` +- `\n` +- `- ` +- `\n` +- `\n` +- `{% endlist %}` + +## Plugins + +- `dragAutoSwitch` + +## Options + +| Option | Type | +|--------|------| +| `tabView` | `ExtensionNodeSpec['view']` | +| `tabsListView` | `ExtensionNodeSpec['view']` | +| `tabPanelView` | `ExtensionNodeSpec['view']` | +| `tabsView` | `ExtensionNodeSpec['view']` | +| `vtabView` | `ExtensionNodeSpec['view']` | +| `vtabInputView` | `ExtensionNodeSpec['view']` | + +## Syntax Guide + +The exact syntax is inferred from serializer hints: + +- `{% list tabs %}` appears in the serializer implementation and documents the expected markup shape. +- `\n` appears in the serializer implementation and documents the expected markup shape. +- `\n` appears in the serializer implementation and documents the expected markup shape. +- `- ` appears in the serializer implementation and documents the expected markup shape. + +## Use Cases + +- Enable it when your editor setup needs nodes `yfm_radio_tabs`, `yfm_tab`, `yfm_tabs_list`, `yfm_tab_panel`. +- Use it when toolbar buttons, slash commands, or shortcuts should trigger 1 related editor action. +- Keep it aligned with `YfmPreset`, `FullPreset` when you want behavior consistent with the standard preset stack. +- Include it when your editor flow depends on the 1 ProseMirror plugin wired by this extension. diff --git a/docs/how-to-generate-extension-docs.md b/docs/how-to-generate-extension-docs.md new file mode 100644 index 00000000..75db374e --- /dev/null +++ b/docs/how-to-generate-extension-docs.md @@ -0,0 +1,39 @@ +##### Develop / Extension docs generation + +## How to Generate Extension Docs + +The extension docs pipeline has two kinds of inputs: + +- hand-written docs from `docs/`, which are converted into `docs-src/`; +- tracked enriched extension docs from `docs-gen/enriched/*.md`, which are assembled into the published extensions reference. + +## Authoring Workflow + +1. Run `pnpm docs:extract` to refresh `docs-gen/raw/*.md` and `docs-gen/extensions.json`. +2. Fill every `` marker and save the result to `docs-gen/enriched/{Name}.md`. +3. Commit the updated `docs-gen/enriched/*.md` files together with any script changes. +4. Run `pnpm docs:assemble` to rebuild the extensions section inside `docs-src/`. +5. Run `pnpm docs:build` when you need a full local docs site build. + +## Enrichment Options + +- `pnpm docs:enrich:prompts` exports prompts for manual or external processing. +- `pnpm docs:enrich` sends prompts to OpenAI directly and writes results into `docs-gen/enriched/`. +- `pnpm docs:enrich:apply` applies prepared JSON responses from `docs-gen/responses/`. +- `pnpm docs:enrich:agent` prints the instruction file for agent-driven enrichment. + +## Strict Publish Rules + +`docs:assemble`, `docs:build`, and `ci:docs:build` are strict publish commands: + +- every publishable extension from `docs-gen/extensions.json` must have a matching `docs-gen/enriched/{Name}.md`; +- enriched docs must not contain `AI:NEEDED` or `AI:FAILED` markers; +- orphan enriched docs are rejected; +- stale files under `docs-src/extensions/` are removed before new pages are written. + +The pipeline excludes internal extensions such as `BaseInputRules`, `BaseKeymap`, `BaseStyles`, `ReactRenderer`, and `SharedState`. They are not published and should not have committed enriched pages. + +## Verification + +- Run `pnpm docs:test` for pipeline regressions. +- Run `node scripts/docs/index.mjs build` to verify the strict generator and assembler flow before publishing. diff --git a/package.json b/package.json index 7f993e7d..23d39249 100644 --- a/package.json +++ b/package.json @@ -29,15 +29,16 @@ "docs:assemble": "node scripts/docs/index.mjs generate && node scripts/docs/index.mjs assemble", "docs:build": "node scripts/docs/index.mjs build && npx -y @diplodoc/cli -i ./docs-src -o ./docs-dist", "docs:generate": "node scripts/docs/index.mjs extract && node scripts/docs/index.mjs enrich --mode prompts", + "docs:test": "node --test scripts/docs/*.test.mjs scripts/docs/extractor/*.test.mjs", "ci:test:visual": "nx playwright @markdown-editor/demo", - "ci:test:unit": "nx run-many -t test --verbose", + "ci:test:unit": "pnpm docs:test && nx run-many -t test --verbose", "ci:test:esbuild": "nx run-many -t test:esbuild --verbose", "ci:test:circular-deps": "nx run-many -t test:circular-deps --verbose", "start": "nx sb:start @markdown-editor/demo", "clean": "nx run-many -t clean", "build": "nx run-many -t build", "typecheck": "nx run-many -t typecheck", - "test": "nx run-many -t test,test:esbuild,test:circular-deps", + "test": "pnpm docs:test && nx run-many -t test,test:esbuild,test:circular-deps", "test:e2e": "nx playwright:docker @markdown-editor/demo", "test:e2e:report": "nx playwright:docker:report @markdown-editor/demo", "lint": "run-p -cs lint:*", diff --git a/scripts/docs/assembler.mjs b/scripts/docs/assembler.mjs index 217f88bf..e53d34af 100644 --- a/scripts/docs/assembler.mjs +++ b/scripts/docs/assembler.mjs @@ -1,12 +1,12 @@ -import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'; +import {existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync} from 'node:fs'; import {basename, join} from 'node:path'; -import process from 'node:process'; -import {config} from './config.mjs'; +import {config, isInternalExtension} from './config.mjs'; import {logger} from './logger.mjs'; import {parseFrontmatter, slugify, stripFrontmatter, yamlQuote} from './utils.mjs'; const {order: CATEGORY_ORDER, labels: CATEGORY_LABELS} = config.categories; +const AI_SECTION_RE = //; /** * Assembles enriched/raw extension docs into the docs-src/ output directory @@ -26,28 +26,32 @@ export class Assembler { */ run() { if (!existsSync(this.rawDir)) { - logger.error(`${this.rawDir} not found. Run extract first.`); - process.exit(1); + const message = `${this.rawDir} not found. Run extract first.`; + logger.error(message); + throw new Error(message); } if (!existsSync(this.outDir)) { - logger.error(`${this.outDir} not found. Run generate first.`); - process.exit(1); + const message = `${this.outDir} not found. Run generate first.`; + logger.error(message); + throw new Error(message); } const extensions = existsSync(this.irPath) ? JSON.parse(readFileSync(this.irPath, 'utf-8')) : []; + const publishableExtensions = this.getPublishableExtensions(extensions); - const version = this.resolveVersion(extensions); + const version = this.resolveVersion(publishableExtensions); logger.info(`Assembling extension docs for v${version}...`); const docs = this.collectDocs(); logger.info( - `Found ${docs.size} extension docs (enriched: ${[...docs.values()].filter((d) => d.source === 'enriched').length})`, + `Found ${docs.rawDocs.size} raw docs and ${docs.enrichedDocs.size} enriched docs`, ); - const pages = this.writePages(docs, extensions); - this.writeIndex(pages, extensions, version); + const publishDocs = this.validateDocs(docs, publishableExtensions); + const pages = this.writePages(publishDocs, publishableExtensions); + this.writeIndex(pages, publishableExtensions, version); const tocItems = this.generateTocItems(pages); this.patchTocYaml(tocItems); @@ -61,14 +65,14 @@ export class Assembler { * Collects docs preferring enriched over raw */ collectDocs() { - const docs = new Map(); + const rawDocs = new Map(); + const enrichedDocs = new Map(); if (existsSync(this.rawDir)) { for (const file of readdirSync(this.rawDir).filter((f) => f.endsWith('.md'))) { const name = basename(file, '.md'); - docs.set(name, { + rawDocs.set(name, { name, - source: 'raw', content: readFileSync(join(this.rawDir, file), 'utf-8'), }); } @@ -77,27 +81,70 @@ export class Assembler { if (existsSync(this.enrichedDir)) { for (const file of readdirSync(this.enrichedDir).filter((f) => f.endsWith('.md'))) { const name = basename(file, '.md'); - docs.set(name, { + enrichedDocs.set(name, { name, - source: 'enriched', content: readFileSync(join(this.enrichedDir, file), 'utf-8'), }); } } - return docs; + return {rawDocs, enrichedDocs}; + } + + getPublishableExtensions(extensions) { + return extensions.filter((extension) => !isInternalExtension(extension.name)); + } + + validateDocs(docs, extensions) { + const publishDocs = new Map(); + const expectedNames = new Set(extensions.map((extension) => extension.name)); + const orphanDocs = [...docs.enrichedDocs.keys()].filter((name) => !expectedNames.has(name)); + + if (orphanDocs.length > 0) { + const message = `Found orphan enriched docs: ${orphanDocs.sort().join(', ')}`; + logger.error(message); + throw new Error(message); + } + + const missingEnriched = []; + for (const extension of extensions) { + if (!docs.enrichedDocs.has(extension.name)) { + missingEnriched.push(extension.name); + continue; + } + + const doc = docs.enrichedDocs.get(extension.name); + if (AI_SECTION_RE.test(doc.content)) { + const message = `Enriched doc for ${extension.name} still contains unresolved AI markers`; + logger.error(message); + throw new Error(message); + } + publishDocs.set(extension.name, doc); + } + + if (missingEnriched.length > 0) { + const message = `Missing enriched docs for publishable extensions: ${missingEnriched.sort().join(', ')}`; + logger.error(message); + throw new Error(message); + } + + return publishDocs; } /** * Writes individual extension pages to docs-src/extensions/ */ writePages(docs, extensions) { + rmSync(this.extensionsOutDir, {recursive: true, force: true}); mkdirSync(this.extensionsOutDir, {recursive: true}); const pages = []; - for (const [name, doc] of docs) { - const extInfo = extensions.find((e) => e.name === name); - const category = extInfo?.category || parseFrontmatter(doc.content).category || 'other'; + for (const extInfo of extensions) { + const doc = docs.get(extInfo.name); + if (!doc) continue; + + const name = extInfo.name; + const category = extInfo.category || parseFrontmatter(doc.content).category || 'other'; const slug = slugify(name); writeFileSync(join(this.extensionsOutDir, `${slug}.md`), stripFrontmatter(doc.content)); @@ -110,7 +157,7 @@ export class Assembler { hasNodes: extInfo?.nodes?.length > 0, hasMarks: extInfo?.marks?.length > 0, hasActions: extInfo?.actions?.length > 0, - source: doc.source, + source: 'enriched', }); } diff --git a/scripts/docs/assembler.test.mjs b/scripts/docs/assembler.test.mjs new file mode 100644 index 00000000..cf65e98e --- /dev/null +++ b/scripts/docs/assembler.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import {existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import {afterEach, test} from 'node:test'; + +import {Assembler} from './assembler.mjs'; + +const tempDirs = []; + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), {recursive: true, force: true}); + } +}); + +function writeFixtureFile(filePath, content) { + mkdirSync(dirname(filePath), {recursive: true}); + writeFileSync(filePath, content); +} + +function createAssemblerFixture() { + const rootDir = mkdtempSync(join(tmpdir(), 'markdown-editor-assembler-')); + tempDirs.push(rootDir); + + const docsGenDir = join(rootDir, 'docs-gen'); + const docsSrcDir = join(rootDir, 'docs-src'); + + writeFixtureFile( + join(docsGenDir, 'raw', 'Bold.md'), + ['---', 'extension: Bold', 'version: 1.2.3', 'category: markdown', '---', '', '# Bold'].join( + '\n', + ), + ); + writeFixtureFile( + join(docsGenDir, 'extensions.json'), + JSON.stringify([{name: 'Bold', category: 'markdown', marks: ['bold'], nodes: [], actions: []}], null, 2), + ); + writeFixtureFile(join(docsSrcDir, 'index.md'), '# Markdown Editor\n'); + writeFixtureFile( + join(docsSrcDir, 'toc.yaml'), + ['title: Markdown Editor', 'href: index.md', 'items:', ' - name: Overview', ' href: index.md'].join( + '\n', + ), + ); + + return {docsGenDir, docsSrcDir}; +} + +test('assemble fails when enriched docs are missing for publishable extensions', () => { + const {docsGenDir, docsSrcDir} = createAssemblerFixture(); + const assembler = new Assembler(docsGenDir, docsSrcDir); + + assert.throws( + () => assembler.run(), + /Missing enriched docs for publishable extensions: Bold/, + ); +}); + +test('assemble fails when enriched docs still contain AI markers', () => { + const {docsGenDir, docsSrcDir} = createAssemblerFixture(); + writeFixtureFile( + join(docsGenDir, 'enriched', 'Bold.md'), + ['---', 'extension: Bold', 'version: 1.2.3', 'category: markdown', '---', '', ''].join( + '\n', + ), + ); + + const assembler = new Assembler(docsGenDir, docsSrcDir); + assert.throws( + () => assembler.run(), + /Enriched doc for Bold still contains unresolved AI markers/, + ); +}); + +test('assemble fails when orphan enriched docs are present', () => { + const {docsGenDir, docsSrcDir} = createAssemblerFixture(); + writeFixtureFile( + join(docsGenDir, 'enriched', 'Bold.md'), + ['---', 'extension: Bold', 'version: 1.2.3', 'category: markdown', '---', '', 'Bold description.'].join( + '\n', + ), + ); + writeFixtureFile( + join(docsGenDir, 'enriched', 'Ghost.md'), + ['---', 'extension: Ghost', 'version: 1.2.3', 'category: markdown', '---', '', 'Ghost description.'].join( + '\n', + ), + ); + + const assembler = new Assembler(docsGenDir, docsSrcDir); + assert.throws(() => assembler.run(), /Found orphan enriched docs: Ghost/); +}); + +test('assemble removes stale extension pages before writing refreshed output', () => { + const {docsGenDir, docsSrcDir} = createAssemblerFixture(); + writeFixtureFile( + join(docsGenDir, 'enriched', 'Bold.md'), + [ + '---', + 'extension: Bold', + 'version: 1.2.3', + 'category: markdown', + '---', + '', + '# Bold', + '', + 'Bold description.', + '', + '## Use Cases', + '', + '- Enable inline emphasis.', + ].join('\n'), + ); + writeFixtureFile(join(docsSrcDir, 'extensions', 'stale.md'), 'stale'); + + const assembler = new Assembler(docsGenDir, docsSrcDir); + assembler.run(); + + assert.equal(existsSync(join(docsSrcDir, 'extensions', 'stale.md')), false); + assert.equal(existsSync(join(docsSrcDir, 'extensions', 'bold.md')), true); + assert.match(readFileSync(join(docsSrcDir, 'extensions', 'bold.md'), 'utf-8'), /Bold description\./); + assert.match(readFileSync(join(docsSrcDir, 'extensions-index.md'), 'utf-8'), /\[Bold\]\(extensions\/bold\.md\)/); +}); diff --git a/scripts/docs/config.mjs b/scripts/docs/config.mjs index b64a244c..5d2778af 100644 --- a/scripts/docs/config.mjs +++ b/scripts/docs/config.mjs @@ -1,3 +1,5 @@ +const internalExtensions = ['BaseInputRules', 'BaseKeymap', 'BaseStyles', 'ReactRenderer', 'SharedState']; + /** * Configuration for the extension documentation generation pipeline */ @@ -105,7 +107,7 @@ Write in markdown format.`, }, }, - skipEnrichment: ['BaseInputRules', 'BaseKeymap', 'BaseStyles', 'ReactRenderer', 'SharedState'], + internalExtensions, categories: { order: ['markdown', 'yfm', 'additional', 'behavior', 'base'], @@ -118,3 +120,7 @@ Write in markdown format.`, }, }, }; + +export function isInternalExtension(name) { + return config.internalExtensions.includes(name); +} diff --git a/scripts/docs/enrich-agent.md b/scripts/docs/enrich-agent.md index 4a4bdaf3..90e8df29 100644 --- a/scripts/docs/enrich-agent.md +++ b/scripts/docs/enrich-agent.md @@ -10,6 +10,7 @@ You are enriching documentation for the `@gravity-ui/markdown-editor` library. - Find all `` markers. - For each marker, read the extension source code at the path from `dirPath` in the IR, then write a replacement text (see section templates below). - Write the result to `docs-gen/enriched/{Name}.md` — same content as raw but with markers replaced by your text. + - Keep the file ready for commit: `docs-gen/enriched/*.md` is a publish input for strict docs builds. 3. Skip these extensions (infrastructure, no user-facing docs needed): - BaseInputRules, BaseKeymap, BaseStyles, ReactRenderer, SharedState @@ -43,6 +44,13 @@ Each extension IR entry has `dirPath` — relative path to the extension directo Take the raw file content, replace each `` with your text, write to `docs-gen/enriched/{Name}.md`. Keep everything else unchanged (frontmatter, deterministic sections, etc.). +## Publish requirement + +`docs:assemble`, `docs:build`, and `ci:docs:build` are strict. They fail if: +- a publishable extension has no matching `docs-gen/enriched/{Name}.md`; +- an enriched file still contains `AI:NEEDED` or `AI:FAILED` markers; +- an orphan enriched file exists for an extension that is no longer in the IR. + ## Scope control - `--all` — enrich all extensions (default) diff --git a/scripts/docs/enricher.mjs b/scripts/docs/enricher.mjs index ea42c09e..737a1b55 100644 --- a/scripts/docs/enricher.mjs +++ b/scripts/docs/enricher.mjs @@ -1,8 +1,7 @@ import {existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync} from 'node:fs'; import {basename, join} from 'node:path'; -import process from 'node:process'; -import {config} from './config.mjs'; +import {config, isInternalExtension} from './config.mjs'; import {logger} from './logger.mjs'; const AI_MARKER_RE = //g; @@ -24,12 +23,14 @@ export class Enricher { */ load() { if (!existsSync(this.rawDir)) { - logger.error(`${this.rawDir} not found. Run extract first.`); - process.exit(1); + const message = `${this.rawDir} not found. Run extract first.`; + logger.error(message); + throw new Error(message); } if (!existsSync(this.irPath)) { - logger.error(`${this.irPath} not found. Run extract first.`); - process.exit(1); + const message = `${this.irPath} not found. Run extract first.`; + logger.error(message); + throw new Error(message); } this.extensions = JSON.parse(readFileSync(this.irPath, 'utf-8')); @@ -47,7 +48,7 @@ export class Enricher { for (const file of this.rawFiles) { const extName = basename(file, '.md'); if (opts.only && !opts.only.includes(extName)) continue; - if (config.skipEnrichment?.includes(extName)) continue; + if (isInternalExtension(extName)) continue; const rawContent = readFileSync(join(this.rawDir, file), 'utf-8'); const extInfo = this.extensions.find((e) => e.name === extName); @@ -90,6 +91,7 @@ export class Enricher { for (const file of this.rawFiles) { const extName = basename(file, '.md'); if (opts.only && !opts.only.includes(extName)) continue; + if (isInternalExtension(extName)) continue; const rawContent = readFileSync(join(this.rawDir, file), 'utf-8'); const extInfo = this.extensions.find((e) => e.name === extName); @@ -149,6 +151,7 @@ export class Enricher { let count = 0; for (const file of this.rawFiles) { const extName = basename(file, '.md'); + if (isInternalExtension(extName)) continue; const responsePath = join(responsesDir, `${extName}.json`); if (!existsSync(responsePath)) continue; diff --git a/scripts/docs/extractor/index.mjs b/scripts/docs/extractor/index.mjs index f24ad4a2..54f24055 100644 --- a/scripts/docs/extractor/index.mjs +++ b/scripts/docs/extractor/index.mjs @@ -1,6 +1,7 @@ import {existsSync, mkdirSync, rmSync, writeFileSync} from 'node:fs'; import {basename, dirname, join, relative} from 'node:path'; +import {isInternalExtension} from '../config.mjs'; import {logger} from '../logger.mjs'; import {listDirs, readAllTsFiles, readText} from '../utils.mjs'; @@ -114,6 +115,7 @@ export class ExtensionExtractor { for (const category of CATEGORIES) { const catDir = join(this.extensionsDir, category); for (const dir of listDirs(catDir)) { + if (isInternalExtension(dir)) continue; const extDir = join(catDir, dir); try { extensions.push(this.scan(extDir, category)); diff --git a/scripts/docs/extractor/markdown-gen.mjs b/scripts/docs/extractor/markdown-gen.mjs index 29bc05ea..5f6dce86 100644 --- a/scripts/docs/extractor/markdown-gen.mjs +++ b/scripts/docs/extractor/markdown-gen.mjs @@ -12,7 +12,6 @@ export function generateRawMd(ext, presetMap, version) { lines.push(`extension: ${ext.name}`); lines.push(`version: ${version}`); lines.push(`category: ${ext.category}`); - lines.push(`generated: ${new Date().toISOString()}`); lines.push('---'); lines.push(''); lines.push(`# ${ext.name}`); diff --git a/scripts/docs/extractor/regex.mjs b/scripts/docs/extractor/regex.mjs index a54c9fb1..e48ab041 100644 --- a/scripts/docs/extractor/regex.mjs +++ b/scripts/docs/extractor/regex.mjs @@ -1,5 +1,6 @@ /** * Extracts ProseMirror node registrations from builder.addNode() calls + * @param content */ export function extractAddNode(content) { const nodes = []; @@ -19,6 +20,7 @@ export function extractAddNode(content) { /** * Extracts ProseMirror mark registrations from builder.addMark() calls + * @param content */ export function extractAddMark(content) { const marks = []; @@ -38,6 +40,7 @@ export function extractAddMark(content) { /** * Extracts node specs from .addNodeSpec({ name: ... }) calls + * @param content */ export function extractNodeSpecs(content) { const nodes = []; @@ -51,6 +54,7 @@ export function extractNodeSpecs(content) { /** * Extracts mark specs from .addMarkSpec({ name: ... }) calls + * @param content */ export function extractMarkSpecs(content) { const marks = []; @@ -64,6 +68,7 @@ export function extractMarkSpecs(content) { /** * Extracts action IDs from .addAction() calls + * @param content */ export function extractActions(content) { const actions = []; @@ -77,6 +82,7 @@ export function extractActions(content) { /** * Extracts plugin function names from .addPlugin() calls + * @param content */ export function extractPlugins(content) { const plugins = []; @@ -88,29 +94,537 @@ export function extractPlugins(content) { return plugins; } +function skipWhitespace(content, index) { + let cursor = index; + while (cursor < content.length && /\s/.test(content[cursor])) { + cursor++; + } + return cursor; +} + +function readBalanced(content, startIndex, openChar, closeChar) { + let depth = 0; + let quote = null; + let inBlockComment = false; + let inLineComment = false; + + for (let index = startIndex; index < content.length; index++) { + const char = content[index]; + const next = content[index + 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + index++; + } + continue; + } + + if (quote) { + if (char === '\\') { + index++; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + inLineComment = true; + index++; + continue; + } + + if (char === '/' && next === '*') { + inBlockComment = true; + index++; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + + if (char === openChar) { + depth++; + continue; + } + + if (char === closeChar) { + depth--; + if (depth === 0) { + return {body: content.slice(startIndex + 1, index), endIndex: index}; + } + } + } + + return null; +} + +function readExpression(content, startIndex, stopChars) { + let parenDepth = 0; + let braceDepth = 0; + let bracketDepth = 0; + let quote = null; + let inBlockComment = false; + let inLineComment = false; + + for (let index = startIndex; index < content.length; index++) { + const char = content[index]; + const next = content[index + 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + index++; + } + continue; + } + + if (quote) { + if (char === '\\') { + index++; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + inLineComment = true; + index++; + continue; + } + + if (char === '/' && next === '*') { + inBlockComment = true; + index++; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + + if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '{') braceDepth++; + else if (char === '}') braceDepth--; + else if (char === '[') bracketDepth++; + else if (char === ']') bracketDepth--; + + if ( + parenDepth === 0 && + braceDepth === 0 && + bracketDepth === 0 && + stopChars.includes(char) + ) { + return {body: content.slice(startIndex, index).trim(), endIndex: index}; + } + } + + return {body: content.slice(startIndex).trim(), endIndex: content.length}; +} + +function splitTopLevel(content) { + const parts = []; + let segmentStart = 0; + let parenDepth = 0; + let braceDepth = 0; + let bracketDepth = 0; + let quote = null; + let inBlockComment = false; + let inLineComment = false; + + for (let index = 0; index < content.length; index++) { + const char = content[index]; + const next = content[index + 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + index++; + } + continue; + } + + if (quote) { + if (char === '\\') { + index++; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + inLineComment = true; + index++; + continue; + } + + if (char === '/' && next === '*') { + inBlockComment = true; + index++; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + + if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '{') braceDepth++; + else if (char === '}') braceDepth--; + else if (char === '[') bracketDepth++; + else if (char === ']') bracketDepth--; + + if (char === ',' && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) { + parts.push(content.slice(segmentStart, index).trim()); + segmentStart = index + 1; + } + } + + const tail = content.slice(segmentStart).trim(); + if (tail) parts.push(tail); + + return parts; +} + +function findArrowIndex(content, startIndex) { + let parenDepth = 0; + let braceDepth = 0; + let bracketDepth = 0; + let quote = null; + let inBlockComment = false; + let inLineComment = false; + + for (let index = startIndex; index < content.length; index++) { + const char = content[index]; + const next = content[index + 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + index++; + } + continue; + } + + if (quote) { + if (char === '\\') { + index++; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + inLineComment = true; + index++; + continue; + } + + if (char === '/' && next === '*') { + inBlockComment = true; + index++; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + + if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '{') braceDepth++; + else if (char === '}') braceDepth--; + else if (char === '[') bracketDepth++; + else if (char === ']') bracketDepth--; + + if ( + parenDepth === 0 && + braceDepth === 0 && + bracketDepth === 0 && + char === '=' && + next === '>' + ) { + return index; + } + } + + return -1; +} + +function extractObjectLiteralKeys(content, knownObjects = new Map()) { + let objectBody = content.trim(); + if (objectBody.startsWith('(') && objectBody.endsWith(')')) { + objectBody = objectBody.slice(1, -1).trim(); + } + if (objectBody.startsWith('{') && objectBody.endsWith('}')) { + objectBody = objectBody.slice(1, -1); + } + + const keys = []; + for (const segment of splitTopLevel(objectBody)) { + if (!segment) continue; + if (segment.startsWith('...')) { + const spreadName = segment.slice(3).trim(); + if (knownObjects.has(spreadName)) { + keys.push(...knownObjects.get(spreadName)); + } + continue; + } + + const colonIndex = segment.indexOf(':'); + if (colonIndex === -1) continue; + + const rawKey = segment.slice(0, colonIndex).trim(); + if (!rawKey || rawKey.startsWith('[')) continue; + + if ( + (rawKey.startsWith('"') && rawKey.endsWith('"')) || + (rawKey.startsWith("'") && rawKey.endsWith("'")) + ) { + keys.push(rawKey.slice(1, -1)); + continue; + } + + if (/^[A-Za-z_$][\w$]*$/.test(rawKey)) { + keys.push(rawKey); + } + } + + return keys; +} + +function extractKnownKeymapObjects(blockBody) { + const knownObjects = new Map(); + + const declarationRe = /(?:const|let|var)\s+(\w+)(?::[^=;]+)?=\s*\{/g; + let match; + while ((match = declarationRe.exec(blockBody))) { + const objectStart = blockBody.indexOf('{', match.index); + const objectLiteral = readBalanced(blockBody, objectStart, '{', '}'); + if (!objectLiteral) continue; + knownObjects.set( + match[1], + extractObjectLiteralKeys(`{${objectLiteral.body}}`, knownObjects), + ); + declarationRe.lastIndex = objectLiteral.endIndex + 1; + } + + const staticComputedAssignmentRe = /(\w+)\s*\[\s*(?:'([^']+)'|"([^"]+)")\s*\]\s*=/g; + while ((match = staticComputedAssignmentRe.exec(blockBody))) { + const objectName = match[1]; + const key = match[2] || match[3]; + if (!key) continue; + const keys = knownObjects.get(objectName) || []; + keys.push(key); + knownObjects.set(objectName, keys); + } + + const staticMemberAssignmentRe = /(\w+)\.(\w+)\s*=/g; + while ((match = staticMemberAssignmentRe.exec(blockBody))) { + const objectName = match[1]; + const key = match[2]; + const keys = knownObjects.get(objectName) || []; + keys.push(key); + knownObjects.set(objectName, keys); + } + + return new Map([...knownObjects.entries()].map(([name, keys]) => [name, [...new Set(keys)]])); +} + +function extractReturnedKeys(blockBody) { + const knownObjects = extractKnownKeymapObjects(blockBody); + let parenDepth = 0; + let braceDepth = 0; + let bracketDepth = 0; + let quote = null; + let inBlockComment = false; + let inLineComment = false; + + for (let index = 0; index < blockBody.length; index++) { + const char = blockBody[index]; + const next = blockBody[index + 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + continue; + } + + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + index++; + } + continue; + } + + if (quote) { + if (char === '\\') { + index++; + continue; + } + if (char === quote) { + quote = null; + } + continue; + } + + if (char === '/' && next === '/') { + inLineComment = true; + index++; + continue; + } + + if (char === '/' && next === '*') { + inBlockComment = true; + index++; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + + if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '{') braceDepth++; + else if (char === '}') braceDepth--; + else if (char === '[') bracketDepth++; + else if (char === ']') bracketDepth--; + + if (parenDepth !== 0 || braceDepth !== 0 || bracketDepth !== 0) continue; + if (!blockBody.startsWith('return', index)) continue; + + const before = blockBody[index - 1]; + const after = blockBody[index + 6]; + if ((before && /\w/.test(before)) || (after && /\w/.test(after))) continue; + + const cursor = skipWhitespace(blockBody, index + 6); + const startChar = blockBody[cursor]; + + if (startChar === '{') { + const objectLiteral = readBalanced(blockBody, cursor, '{', '}'); + return objectLiteral + ? extractObjectLiteralKeys(`{${objectLiteral.body}}`, knownObjects) + : []; + } + + if (startChar === '(' && blockBody[skipWhitespace(blockBody, cursor + 1)] === '{') { + const expression = readBalanced(blockBody, cursor, '(', ')'); + return expression ? extractObjectLiteralKeys(`(${expression.body})`, knownObjects) : []; + } + + const expression = readExpression(blockBody, cursor, [';']); + const returnedName = expression.body.trim(); + return knownObjects.get(returnedName) || []; + } + + return []; +} + +function extractKeymapCallbackBodies(content) { + const bodies = []; + let index = 0; + + while ((index = content.indexOf('.addKeymap(', index)) !== -1) { + const arrowIndex = findArrowIndex(content, index + '.addKeymap('.length); + if (arrowIndex === -1) { + index += '.addKeymap('.length; + continue; + } + + const bodyStart = skipWhitespace(content, arrowIndex + 2); + const startChar = content[bodyStart]; + + if (startChar === '{') { + const blockBody = readBalanced(content, bodyStart, '{', '}'); + if (blockBody) { + bodies.push({type: 'block', body: blockBody.body}); + index = blockBody.endIndex + 1; + continue; + } + } + + if (startChar === '(' && content[skipWhitespace(content, bodyStart + 1)] === '{') { + const expressionBody = readBalanced(content, bodyStart, '(', ')'); + if (expressionBody) { + bodies.push({type: 'expression', body: `(${expressionBody.body})`}); + index = expressionBody.endIndex + 1; + continue; + } + } + + const expressionBody = readExpression(content, bodyStart, [',', ')']); + bodies.push({type: 'expression', body: expressionBody.body}); + index = expressionBody.endIndex + 1; + } + + return bodies; +} + /** * Extracts keymap bindings from .addKeymap() callbacks + * @param content */ export function extractKeymaps(content) { const keymaps = []; - const re = /\.addKeymap\(\s*\([^)]*\)\s*=>\s*\(\{([^}]*(?:\{[^}]*\}[^}]*)*)\}\)/gs; - let m; - while ((m = re.exec(content))) { - const block = m[1]; - const keyRe = /['"]?([^'",:]+)['"]?\s*:/g; - let km; - while ((km = keyRe.exec(block))) { - const key = km[1].trim(); - if (key && !key.startsWith('//') && !key.startsWith('...')) { - keymaps.push(key); - } + + for (const callbackBody of extractKeymapCallbackBodies(content)) { + if (callbackBody.type === 'block') { + keymaps.push(...extractReturnedKeys(callbackBody.body)); + continue; + } + + if (callbackBody.body.startsWith('(') || callbackBody.body.startsWith('{')) { + keymaps.push(...extractObjectLiteralKeys(callbackBody.body)); } } + return [...new Set(keymaps)]; } /** * Extracts input rule patterns (markInputRule, wrappingInputRule, etc.) + * @param content */ export function extractInputRules(content) { const rules = []; @@ -129,6 +643,7 @@ export function extractInputRules(content) { /** * Extracts markdown-it plugin registrations from md.use() calls + * @param content */ export function extractMdPlugins(content) { const plugins = []; @@ -142,6 +657,7 @@ export function extractMdPlugins(content) { /** * Extracts the Options type fields from `export type FooOptions = { ... }` + * @param content */ export function extractOptionsType(content) { const fields = []; @@ -163,6 +679,7 @@ export function extractOptionsType(content) { /** * Extracts markup examples from same() assertions in test files + * @param content */ export function extractTestExamples(content) { const examples = []; @@ -180,6 +697,7 @@ export function extractTestExamples(content) { /** * Extracts serializer syntax patterns from state.write() and state.text() calls + * @param content */ export function extractSerializerSyntax(content) { const snippets = []; diff --git a/scripts/docs/extractor/regex.test.mjs b/scripts/docs/extractor/regex.test.mjs new file mode 100644 index 00000000..8e7d766e --- /dev/null +++ b/scripts/docs/extractor/regex.test.mjs @@ -0,0 +1,65 @@ +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {test} from 'node:test'; + +import {extractKeymaps} from './regex.mjs'; + +function readRepoFile(relativePath) { + return readFileSync(new URL(relativePath, import.meta.url), 'utf-8'); +} + +test('extractKeymaps handles direct object returns and ignores computed keys', () => { + const content = [ + 'builder.addKeymap(() => ({', + ' Tab: handleTab,', + " 'Shift-Tab': handleShiftTab,", + ' [dynamicKey]: ignoreMe,', + '}));', + ].join('\n'); + + assert.deepEqual(extractKeymaps(content), ['Tab', 'Shift-Tab']); +}); + +test('extractKeymaps merges returned object literals with spread bindings', () => { + const content = [ + 'builder.addKeymap(() => {', + ' const bindings: Keymap = {Backspace: resetHeading};', + ' return {', + ' Tab: handleTab,', + ' ...bindings,', + " 'Shift-Tab': handleShiftTab,", + ' };', + '});', + ].join('\n'); + + assert.deepEqual(extractKeymaps(content), ['Tab', 'Backspace', 'Shift-Tab']); +}); + +test('extractKeymaps captures static keymaps from the Lists extension', () => { + const content = readRepoFile('../../../packages/editor/src/extensions/markdown/Lists/index.ts'); + + assert.deepEqual(extractKeymaps(content), [ + 'Tab', + 'Shift-Tab', + 'Backspace', + 'Mod-[', + 'Mod-]', + 'Enter', + ]); +}); + +test('extractKeymaps captures static bindings from block-body callbacks and ignores dynamic ones', () => { + const headingContent = readRepoFile( + '../../../packages/editor/src/extensions/markdown/Heading/index.ts', + ); + const historyContent = readRepoFile( + '../../../packages/editor/src/extensions/behavior/History/index.ts', + ); + const editorModeContent = readRepoFile( + '../../../packages/editor/src/extensions/behavior/EditorModeKeymap/index.ts', + ); + + assert.deepEqual(extractKeymaps(headingContent), ['Backspace']); + assert.deepEqual(extractKeymaps(historyContent), []); + assert.deepEqual(extractKeymaps(editorModeContent), []); +}); diff --git a/scripts/docs/generator.mjs b/scripts/docs/generator.mjs index c0a6ceeb..47d04c70 100644 --- a/scripts/docs/generator.mjs +++ b/scripts/docs/generator.mjs @@ -8,7 +8,6 @@ import { writeFileSync, } from 'node:fs'; import {dirname, join} from 'node:path'; -import process from 'node:process'; import {logger} from './logger.mjs'; import {slugify, yamlQuote} from './utils.mjs'; @@ -63,8 +62,9 @@ export class Generator { */ collectDocs() { if (!existsSync(this.docsDir)) { - logger.error(`source directory "${this.docsDir}" does not exist`); - process.exit(1); + const message = `source directory "${this.docsDir}" does not exist`; + logger.error(message); + throw new Error(message); } const files = readdirSync(this.docsDir) @@ -152,10 +152,9 @@ export class Generator { for (const doc of docs) { const outPath = this.computeOutputPath(doc); if (seen.has(outPath)) { - logger.error( - `duplicate output path "${outPath}" from "${doc.sourceFile}" and "${seen.get(outPath)}"`, - ); - process.exit(1); + const message = `duplicate output path "${outPath}" from "${doc.sourceFile}" and "${seen.get(outPath)}"`; + logger.error(message); + throw new Error(message); } seen.set(outPath, doc.sourceFile); } diff --git a/scripts/docs/index.mjs b/scripts/docs/index.mjs index 34cb969c..ce26b812 100644 --- a/scripts/docs/index.mjs +++ b/scripts/docs/index.mjs @@ -1,5 +1,5 @@ -import {existsSync, rmSync} from 'node:fs'; import process from 'node:process'; +import {fileURLToPath} from 'node:url'; import {Assembler} from './assembler.mjs'; import {Enricher} from './enricher.mjs'; @@ -14,9 +14,9 @@ const DOCS_GEN_DIR = 'docs-gen'; /** * Parses CLI arguments into a command and options object + * @param args */ -function parseArgs() { - const args = process.argv.slice(2); +export function parseArgs(args = process.argv.slice(2)) { const command = args[0]; const opts = {mode: 'prompts', only: null, model: null}; @@ -37,91 +37,95 @@ function parseArgs() { return {command, opts}; } -function runGenerate() { - new Generator(DOCS_DIR, DOCS_SRC_DIR).run(); -} - -function runExtract() { - new ExtensionExtractor(EDITOR_PKG, DOCS_GEN_DIR).run(); -} +export function createCommandHandlers(paths = {}) { + const editorPkg = paths.editorPkg || EDITOR_PKG; + const docsDir = paths.docsDir || DOCS_DIR; + const docsSrcDir = paths.docsSrcDir || DOCS_SRC_DIR; + const docsGenDir = paths.docsGenDir || DOCS_GEN_DIR; -async function runEnrich(opts) { - const enricher = new Enricher(DOCS_GEN_DIR); - enricher.load(); - - switch (opts.mode) { - case 'prompts': { - const count = enricher.generatePrompts(opts); - logger.success(`Generated ${count} prompt files in ${DOCS_GEN_DIR}/prompts/`); - logger.info('\nNext steps:'); - logger.info(' - Process prompts through your AI tool'); - logger.info(` - Save responses in ${DOCS_GEN_DIR}/responses/ExtName.json`); - logger.info(' - Run: node scripts/docs/index.mjs enrich --mode apply'); - logger.info('\nOr with OpenAI API:'); - logger.info(' OPENAI_API_KEY=sk-... node scripts/docs/index.mjs enrich --mode enrich'); - break; - } - case 'enrich': { - const count = await enricher.enrichWithAI(opts); - logger.success(`Enriched ${count} docs in ${DOCS_GEN_DIR}/enriched/`); - break; - } - case 'apply': { - const count = enricher.applyResponses(); - logger.success(`Applied responses to ${count} docs in ${DOCS_GEN_DIR}/enriched/`); - break; - } - default: - logger.error(`Unknown enrich mode: ${opts.mode}. Use --mode prompts|enrich|apply`); - process.exit(1); + function runGenerate() { + new Generator(docsDir, docsSrcDir).run(); } -} -function runAssemble() { - new Assembler(DOCS_GEN_DIR, DOCS_SRC_DIR).run(); -} - -function clearEnrichedDocs() { - const enrichedDir = `${DOCS_GEN_DIR}/enriched`; + function runExtract() { + new ExtensionExtractor(editorPkg, docsGenDir).run(); + } - if (existsSync(enrichedDir)) { - logger.info(`Removing stale enriched docs from ${enrichedDir}/`); - rmSync(enrichedDir, {recursive: true, force: true}); + async function runEnrich(opts) { + const enricher = new Enricher(docsGenDir); + enricher.load(); + + switch (opts.mode) { + case 'prompts': { + const count = enricher.generatePrompts(opts); + logger.success(`Generated ${count} prompt files in ${docsGenDir}/prompts/`); + logger.info('\nNext steps:'); + logger.info(' - Process prompts through your AI tool'); + logger.info(` - Save responses in ${docsGenDir}/responses/ExtName.json`); + logger.info(' - Run: node scripts/docs/index.mjs enrich --mode apply'); + logger.info('\nOr with OpenAI API:'); + logger.info( + ' OPENAI_API_KEY=sk-... node scripts/docs/index.mjs enrich --mode enrich', + ); + break; + } + case 'enrich': { + const count = await enricher.enrichWithAI(opts); + logger.success(`Enriched ${count} docs in ${docsGenDir}/enriched/`); + break; + } + case 'apply': { + const count = enricher.applyResponses(); + logger.success(`Applied responses to ${count} docs in ${docsGenDir}/enriched/`); + break; + } + default: + throw new Error( + `Unknown enrich mode: ${opts.mode}. Use --mode prompts|enrich|apply`, + ); + } } -} -/** - * Full pipeline: generate -> extract -> assemble - */ -function runBuild() { - clearEnrichedDocs(); - runGenerate(); - runExtract(); - runAssemble(); -} + function runAssemble() { + new Assembler(docsGenDir, docsSrcDir).run(); + } -async function main() { - const {command, opts} = parseArgs(); + function runBuild() { + runGenerate(); + runExtract(); + runAssemble(); + } - const commands = { + return { generate: runGenerate, extract: runExtract, - enrich: () => runEnrich(opts), + enrich: runEnrich, assemble: runAssemble, build: runBuild, }; +} + +export async function main(args = process.argv.slice(2), paths = {}) { + const {command, opts} = parseArgs(args); + const commands = createCommandHandlers(paths); const handler = commands[command]; if (!handler) { - logger.error(`Unknown command: ${command}`); logger.info('Available commands: generate, extract, enrich, assemble, build'); - process.exit(1); + throw new Error(`Unknown command: ${command}`); + } + + if (command === 'enrich') { + await handler(opts); + return; } await handler(); } -main().catch((err) => { - logger.error(err); - process.exit(1); -}); +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + main().catch((err) => { + logger.error(err); + process.exit(1); + }); +} diff --git a/scripts/docs/index.test.mjs b/scripts/docs/index.test.mjs new file mode 100644 index 00000000..5b63740e --- /dev/null +++ b/scripts/docs/index.test.mjs @@ -0,0 +1,105 @@ +import assert from 'node:assert/strict'; +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; +import {tmpdir} from 'node:os'; +import {dirname, join} from 'node:path'; +import process from 'node:process'; +import {afterEach, test} from 'node:test'; + +import {createCommandHandlers} from './index.mjs'; + +const tempDirs = []; +const originalCwd = process.cwd(); + +afterEach(() => { + process.chdir(originalCwd); + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), {recursive: true, force: true}); + } +}); + +function writeFixtureFile(filePath, content) { + mkdirSync(dirname(filePath), {recursive: true}); + writeFileSync(filePath, content); +} + +function createBuildFixture() { + const rootDir = mkdtempSync(join(tmpdir(), 'markdown-editor-build-')); + tempDirs.push(rootDir); + + writeFixtureFile(join(rootDir, 'docs', 'overview.md'), '##### Overview\n\nOverview body.\n'); + for (const preset of ['zero', 'commonmark', 'default', 'yfm', 'full']) { + writeFixtureFile( + join(rootDir, 'packages', 'editor', 'src', 'presets', `${preset}.ts`), + 'export const preset = true;\n', + ); + } + writeFixtureFile( + join(rootDir, 'packages', 'editor', 'package.json'), + JSON.stringify({name: '@gravity-ui/markdown-editor', version: '1.2.3'}, null, 2), + ); + writeFixtureFile( + join(rootDir, 'packages', 'editor', 'src', 'extensions', 'markdown', 'Bold', 'index.ts'), + [ + 'export const Bold = (builder) => {', + ' builder.use(BoldSpecs);', + " builder.addKeymap(() => ({'Mod-b': toggleBold}));", + '};', + ].join('\n'), + ); + writeFixtureFile( + join( + rootDir, + 'packages', + 'editor', + 'src', + 'extensions', + 'markdown', + 'Bold', + 'BoldSpecs', + 'index.ts', + ), + [ + 'export const BoldSpecs = (builder) => {', + " builder.addMark('bold', () => ({}));", + '};', + ].join('\n'), + ); + writeFixtureFile( + join(rootDir, 'docs-gen', 'enriched', 'Bold.md'), + [ + '---', + 'extension: Bold', + 'version: 1.2.3', + 'category: markdown', + '---', + '', + '# Bold', + '', + 'Adds bold text support.', + '', + '## Use Cases', + '', + '- Enable bold emphasis in markdown content.', + ].join('\n'), + ); + + return rootDir; +} + +test('build succeeds from a clean fixture when matching enriched docs are present', () => { + const rootDir = createBuildFixture(); + process.chdir(rootDir); + + const commands = createCommandHandlers(); + commands.build(); + + assert.equal(existsSync(join(rootDir, 'docs-src', 'extensions', 'bold.md')), true); + assert.match( + readFileSync(join(rootDir, 'docs-src', 'extensions', 'bold.md'), 'utf-8'), + /Adds bold text support\./, + ); + assert.match( + readFileSync(join(rootDir, 'docs-src', 'extensions-index.md'), 'utf-8'), + /\[Bold\]\(extensions\/bold\.md\)/, + ); +}); From 34144e76ce74e353083902ab0399c2d6ce382dfd Mon Sep 17 00:00:00 2001 From: makhnatkin Date: Thu, 7 May 2026 14:48:13 +0200 Subject: [PATCH 4/4] refactor(docs-gen): harden extractor and drop OpenAI direct path - extractConstants: balanced reader for object literals + enum bodies, captures top-level scalar props through nested objects - extractActions: require builder. prefix and support chained .addAction() calls - scanAll: fail-fast instead of swallowing parser errors as warnings - Pin @diplodoc/cli to 5.37.0 in build scripts (drop unpinned npx -y) - enricher: drop redundant new RegExp(AI_MARKER_RE.source,'g'); remove enrichWithAI and callOpenAI - config: drop ai{} block; drop --mode enrich and --model CLI options - IR shape: write {version, extensions} into extensions.json; assembler reads version from IR (drop resolveVersion frontmatter probe) - docs: drop docs:enrich script and OpenAI mention in how-to-generate-extension-docs.md - tests: add extractActions guard tests, constants.test.mjs covering nested objects and reference chains Co-Authored-By: Claude Opus 4.7 --- docs/how-to-generate-extension-docs.md | 1 - package.json | 5 +- scripts/docs/assembler.mjs | 24 ++--- scripts/docs/assembler.test.mjs | 11 ++- scripts/docs/config.mjs | 7 -- scripts/docs/enricher.mjs | 100 +++---------------- scripts/docs/extractor/constants.mjs | 113 ++++++++++++++++++++-- scripts/docs/extractor/constants.test.mjs | 44 +++++++++ scripts/docs/extractor/index.mjs | 11 +-- scripts/docs/extractor/regex.mjs | 12 ++- scripts/docs/extractor/regex.test.mjs | 23 ++++- scripts/docs/index.mjs | 36 +++---- 12 files changed, 228 insertions(+), 159 deletions(-) create mode 100644 scripts/docs/extractor/constants.test.mjs diff --git a/docs/how-to-generate-extension-docs.md b/docs/how-to-generate-extension-docs.md index 75db374e..45bbb05b 100644 --- a/docs/how-to-generate-extension-docs.md +++ b/docs/how-to-generate-extension-docs.md @@ -18,7 +18,6 @@ The extension docs pipeline has two kinds of inputs: ## Enrichment Options - `pnpm docs:enrich:prompts` exports prompts for manual or external processing. -- `pnpm docs:enrich` sends prompts to OpenAI directly and writes results into `docs-gen/enriched/`. - `pnpm docs:enrich:apply` applies prepared JSON responses from `docs-gen/responses/`. - `pnpm docs:enrich:agent` prints the instruction file for agent-driven enrichment. diff --git a/package.json b/package.json index 23d39249..6bf2c000 100644 --- a/package.json +++ b/package.json @@ -20,14 +20,13 @@ "scripts": { "ci:deps": "pnpm i --frozen-lockfile", "ci:demo:build": "nx sb:build @markdown-editor/demo", - "ci:docs:build": "node scripts/docs/index.mjs build && npx -y @diplodoc/cli -i ./docs-src -o ./docs-dist", + "ci:docs:build": "node scripts/docs/index.mjs build && npx -y @diplodoc/cli@5.37.0 -i ./docs-src -o ./docs-dist", "docs:extract": "node scripts/docs/index.mjs extract", "docs:enrich:prompts": "node scripts/docs/index.mjs enrich --mode prompts", - "docs:enrich": "node scripts/docs/index.mjs enrich --mode enrich", "docs:enrich:apply": "node scripts/docs/index.mjs enrich --mode apply", "docs:enrich:agent": "echo 'Give the agent: scripts/docs/enrich-agent.md' && echo 'Raw docs: docs-gen/raw/' && echo 'Output: docs-gen/enriched/'", "docs:assemble": "node scripts/docs/index.mjs generate && node scripts/docs/index.mjs assemble", - "docs:build": "node scripts/docs/index.mjs build && npx -y @diplodoc/cli -i ./docs-src -o ./docs-dist", + "docs:build": "node scripts/docs/index.mjs build && npx -y @diplodoc/cli@5.37.0 -i ./docs-src -o ./docs-dist", "docs:generate": "node scripts/docs/index.mjs extract && node scripts/docs/index.mjs enrich --mode prompts", "docs:test": "node --test scripts/docs/*.test.mjs scripts/docs/extractor/*.test.mjs", "ci:test:visual": "nx playwright @markdown-editor/demo", diff --git a/scripts/docs/assembler.mjs b/scripts/docs/assembler.mjs index e53d34af..c7ed14fe 100644 --- a/scripts/docs/assembler.mjs +++ b/scripts/docs/assembler.mjs @@ -2,6 +2,7 @@ import {existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync} import {basename, join} from 'node:path'; import {config, isInternalExtension} from './config.mjs'; +import {readExtensionsIR} from './enricher.mjs'; import {logger} from './logger.mjs'; import {parseFrontmatter, slugify, stripFrontmatter, yamlQuote} from './utils.mjs'; @@ -36,12 +37,12 @@ export class Assembler { throw new Error(message); } - const extensions = existsSync(this.irPath) - ? JSON.parse(readFileSync(this.irPath, 'utf-8')) - : []; - const publishableExtensions = this.getPublishableExtensions(extensions); + const ir = existsSync(this.irPath) + ? readExtensionsIR(this.irPath) + : {version: 'unknown', extensions: []}; + const publishableExtensions = this.getPublishableExtensions(ir.extensions); - const version = this.resolveVersion(publishableExtensions); + const version = ir.version; logger.info(`Assembling extension docs for v${version}...`); const docs = this.collectDocs(); @@ -268,17 +269,4 @@ export class Assembler { content += `- [Extensions Reference](extensions-index.md) (v${version})\n`; writeFileSync(indexPath, content); } - - /** - * Reads version from the first extension's raw doc frontmatter - */ - resolveVersion(extensions) { - if (extensions[0]) { - const raw = join(this.rawDir, `${extensions[0].name}.md`); - if (existsSync(raw)) { - return parseFrontmatter(readFileSync(raw, 'utf-8')).version || 'unknown'; - } - } - return 'unknown'; - } } diff --git a/scripts/docs/assembler.test.mjs b/scripts/docs/assembler.test.mjs index cf65e98e..a55ed659 100644 --- a/scripts/docs/assembler.test.mjs +++ b/scripts/docs/assembler.test.mjs @@ -34,7 +34,16 @@ function createAssemblerFixture() { ); writeFixtureFile( join(docsGenDir, 'extensions.json'), - JSON.stringify([{name: 'Bold', category: 'markdown', marks: ['bold'], nodes: [], actions: []}], null, 2), + JSON.stringify( + { + version: '1.2.3', + extensions: [ + {name: 'Bold', category: 'markdown', marks: ['bold'], nodes: [], actions: []}, + ], + }, + null, + 2, + ), ); writeFixtureFile(join(docsSrcDir, 'index.md'), '# Markdown Editor\n'); writeFixtureFile( diff --git a/scripts/docs/config.mjs b/scripts/docs/config.mjs index 5d2778af..3d355533 100644 --- a/scripts/docs/config.mjs +++ b/scripts/docs/config.mjs @@ -4,13 +4,6 @@ const internalExtensions = ['BaseInputRules', 'BaseKeymap', 'BaseStyles', 'React * Configuration for the extension documentation generation pipeline */ export const config = { - ai: { - provider: 'openai', - model: 'gpt-4o-mini', - temperature: 0.3, - maxTokens: 1000, - }, - prompts: { description: { system: `You are a technical writer for the @gravity-ui/markdown-editor library — a ProseMirror-based WYSIWYG and markup editor. Write concise, accurate documentation in English.`, diff --git a/scripts/docs/enricher.mjs b/scripts/docs/enricher.mjs index 737a1b55..9cfb1f12 100644 --- a/scripts/docs/enricher.mjs +++ b/scripts/docs/enricher.mjs @@ -6,6 +6,12 @@ import {logger} from './logger.mjs'; const AI_MARKER_RE = //g; +function readExtensionsIR(irPath) { + const parsed = JSON.parse(readFileSync(irPath, 'utf-8')); + if (Array.isArray(parsed)) return {version: 'unknown', extensions: parsed}; + return parsed; +} + /** * Enriches raw extension docs with AI-generated content */ @@ -33,7 +39,9 @@ export class Enricher { throw new Error(message); } - this.extensions = JSON.parse(readFileSync(this.irPath, 'utf-8')); + const ir = readExtensionsIR(this.irPath); + this.version = ir.version; + this.extensions = ir.extensions; this.rawFiles = readdirSync(this.rawDir).filter((f) => f.endsWith('.md')); logger.info(`Found ${this.rawFiles.length} raw docs, ${this.extensions.length} extensions`); } @@ -54,9 +62,7 @@ export class Enricher { const extInfo = this.extensions.find((e) => e.name === extName); if (!extInfo) continue; - const markers = [...rawContent.matchAll(new RegExp(AI_MARKER_RE.source, 'g'))].map( - (m) => m[1], - ); + const markers = [...rawContent.matchAll(AI_MARKER_RE)].map((m) => m[1]); if (markers.length === 0) continue; const sourceCode = this.readExtensionSource(extInfo); @@ -81,56 +87,6 @@ export class Enricher { return count; } - /** - * Enriches raw docs by calling the OpenAI API - */ - async enrichWithAI(opts) { - mkdirSync(this.enrichedDir, {recursive: true}); - - let count = 0; - for (const file of this.rawFiles) { - const extName = basename(file, '.md'); - if (opts.only && !opts.only.includes(extName)) continue; - if (isInternalExtension(extName)) continue; - - const rawContent = readFileSync(join(this.rawDir, file), 'utf-8'); - const extInfo = this.extensions.find((e) => e.name === extName); - if (!extInfo) continue; - - const sourceCode = this.readExtensionSource(extInfo); - let enrichedContent = rawContent; - let enriched = false; - - const replacements = []; - for (const match of rawContent.matchAll(new RegExp(AI_MARKER_RE.source, 'g'))) { - const section = match[1]; - const marker = match[0]; - const prompt = this.buildPrompt(section, extName, rawContent, sourceCode, extInfo); - - logger.info(` Enriching ${extName}.${section}...`); - try { - const result = await this.callOpenAI(prompt, opts.model); - replacements.push({marker, result}); - enriched = true; - } catch (err) { - logger.warn(`failed to enrich ${extName}.${section}: ${err.message}`); - replacements.push({marker, result: ``}); - } - } - - for (const {marker, result} of replacements) { - enrichedContent = enrichedContent.replace(marker, result); - } - - if (enriched) { - writeFileSync(join(this.enrichedDir, `${extName}.md`), enrichedContent); - count++; - } - } - - return count; - } - /** * Applies manually prepared AI responses from docs-gen/responses/ directory */ @@ -141,10 +97,10 @@ export class Enricher { if (!existsSync(responsesDir)) { logger.info(`No responses directory found at ${responsesDir}`); logger.info('To use manual enrichment:'); - logger.info(' 1. Run: node scripts/docs/index.mjs enrich --mode prompts'); + logger.info(' 1. Run: pnpm docs:enrich:prompts'); logger.info(` 2. Process prompts from ${this.promptsDir}/`); logger.info(` 3. Save responses in ${responsesDir}/ExtName.json`); - logger.info(' 4. Run: node scripts/docs/index.mjs enrich --mode apply'); + logger.info(' 4. Run: pnpm docs:enrich:apply'); return 0; } @@ -229,34 +185,6 @@ export class Enricher { const interpolate = (tpl) => tpl.replace(/\{(\w+)\}/g, (_, key) => vars[key] ?? ''); return `${interpolate(templateDef.system)}\n\n${interpolate(templateDef.user)}`; } - - /** - * Calls the OpenAI chat completions API - */ - async callOpenAI(prompt, model) { - const apiKey = process.env.OPENAI_API_KEY; - if (!apiKey) throw new Error('OPENAI_API_KEY environment variable is required'); - - const response = await fetch('https://api.openai.com/v1/chat/completions', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${apiKey}`, - }, - body: JSON.stringify({ - model: model || config.ai.model, - messages: [{role: 'user', content: prompt}], - temperature: config.ai.temperature, - max_tokens: config.ai.maxTokens, - }), - }); - - if (!response.ok) { - const text = await response.text(); - throw new Error(`OpenAI API error ${response.status}: ${text}`); - } - - const data = await response.json(); - return data.choices[0].message.content.trim(); - } } + +export {readExtensionsIR}; diff --git a/scripts/docs/extractor/constants.mjs b/scripts/docs/extractor/constants.mjs index c2a637af..fb9cf97f 100644 --- a/scripts/docs/extractor/constants.mjs +++ b/scripts/docs/extractor/constants.mjs @@ -1,3 +1,5 @@ +import {readBalanced} from './regex.mjs'; + /** * Extracts constant declarations, enums, and object literals from TypeScript source. * Returns a Map of name -> resolved string value. @@ -13,24 +15,30 @@ export function extractConstants(content) { } // Enum members: enum Foo { Bar = 'baz' } - const enumRe = /(?:export\s+)?enum\s+(\w+)\s*\{([^}]+)\}/g; + const enumRe = /(?:export\s+)?enum\s+(\w+)\s*\{/g; while ((m = enumRe.exec(content))) { const enumName = m[1]; - const entries = m[2].matchAll(/(\w+)\s*=\s*['"]([^'"]+)['"]/g); + const block = readBalanced(content, content.indexOf('{', m.index), '{', '}'); + if (!block) continue; + const entries = block.body.matchAll(/(\w+)\s*=\s*['"]([^'"]+)['"]/g); for (const e of entries) { names.set(`${enumName}.${e[1]}`, e[2]); } + enumRe.lastIndex = block.endIndex + 1; } - // Object literal properties: const Obj = { Prop: 'val' | varRef } - const objRe = /(?:export\s+)?const\s+(\w+)\s*=\s*\{([^}]+)\}/gs; - while ((m = objRe.exec(content))) { + // Object literal properties: const Obj = { Prop: 'val' | varRef, Nested: { ... } } + // Captures only top-level scalar properties; nested objects are skipped via balanced read. + const objStartRe = /(?:export\s+)?const\s+(\w+)\s*=\s*\{/g; + while ((m = objStartRe.exec(content))) { const objName = m[1]; - const propRe = /(\w+)\s*:\s*(?:['"]([^'"]+)['"]|(\w+))/g; - let pm; - while ((pm = propRe.exec(m[2]))) { - names.set(`${objName}.${pm[1]}`, pm[2] || pm[3]); + const braceIndex = content.indexOf('{', m.index); + const block = readBalanced(content, braceIndex, '{', '}'); + if (!block) continue; + for (const [key, value] of extractTopLevelScalarProps(block.body)) { + names.set(`${objName}.${key}`, value); } + objStartRe.lastIndex = block.endIndex + 1; } // Const-to-const references: const A = B @@ -124,3 +132,90 @@ export function resolveAllConstants(rawList, constants) { return [...new Set(resolved)]; } + +/** + * Iterates top-level `key: 'value'` and `key: identifier` pairs from an object literal body. + * Skips nested objects, arrays, and function calls so that callers see only direct scalar props. + */ +function* extractTopLevelScalarProps(body) { + let parenDepth = 0; + let braceDepth = 0; + let bracketDepth = 0; + let quote = null; + let inLineComment = false; + let inBlockComment = false; + let segmentStart = 0; + const segments = []; + + for (let index = 0; index < body.length; index++) { + const char = body[index]; + const next = body[index + 1]; + + if (inLineComment) { + if (char === '\n') inLineComment = false; + continue; + } + if (inBlockComment) { + if (char === '*' && next === '/') { + inBlockComment = false; + index++; + } + continue; + } + if (quote) { + if (char === '\\') { + index++; + continue; + } + if (char === quote) quote = null; + continue; + } + if (char === '/' && next === '/') { + inLineComment = true; + index++; + continue; + } + if (char === '/' && next === '*') { + inBlockComment = true; + index++; + continue; + } + if (char === '"' || char === "'" || char === '`') { + quote = char; + continue; + } + if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '{') braceDepth++; + else if (char === '}') braceDepth--; + else if (char === '[') bracketDepth++; + else if (char === ']') bracketDepth--; + + if (char === ',' && parenDepth === 0 && braceDepth === 0 && bracketDepth === 0) { + segments.push(body.slice(segmentStart, index)); + segmentStart = index + 1; + } + } + const tail = body.slice(segmentStart); + if (tail.trim()) segments.push(tail); + + for (const segment of segments) { + const trimmed = segment.trim(); + if (!trimmed || trimmed.startsWith('...')) continue; + const colon = trimmed.indexOf(':'); + if (colon === -1) continue; + const rawKey = trimmed.slice(0, colon).trim(); + if (!rawKey || rawKey.startsWith('[') || !/^[A-Za-z_$][\w$]*$/.test(rawKey)) continue; + + const rawValue = trimmed.slice(colon + 1).trim(); + const stringMatch = rawValue.match(/^['"]([^'"]*)['"]/); + if (stringMatch) { + yield [rawKey, stringMatch[1]]; + continue; + } + const identMatch = rawValue.match(/^([A-Za-z_$][\w$.]*)/); + if (identMatch) { + yield [rawKey, identMatch[1]]; + } + } +} diff --git a/scripts/docs/extractor/constants.test.mjs b/scripts/docs/extractor/constants.test.mjs new file mode 100644 index 00000000..de613534 --- /dev/null +++ b/scripts/docs/extractor/constants.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import {test} from 'node:test'; + +import {extractConstants, resolveAllConstants} from './constants.mjs'; + +test('extractConstants captures top-level scalar props of an object literal with nested objects', () => { + const content = [ + 'export const CLASSNAMES = {', + " Inline: { Container: 'a', Sharp: 'b' },", + " Block: 'mathblock',", + " Display: 'display',", + '} as const;', + ].join('\n'); + + const map = extractConstants(content); + assert.equal(map.get('CLASSNAMES.Block'), 'mathblock'); + assert.equal(map.get('CLASSNAMES.Display'), 'display'); +}); + +test('extractConstants handles enums declared after objects with nested braces', () => { + const content = [ + 'const NESTED = { Inner: { Foo: "bar" }, Outer: "v" };', + 'export enum NodeName { Para = "paragraph", Doc = "doc" }', + ].join('\n'); + + const map = extractConstants(content); + assert.equal(map.get('NESTED.Outer'), 'v'); + assert.equal(map.get('NodeName.Para'), 'paragraph'); + assert.equal(map.get('NodeName.Doc'), 'doc'); +}); + +test('extractConstants resolves identifier-valued props through reference chains', () => { + const content = ["const NAME = 'bold';", 'export const Specs = { Bold: NAME };'].join('\n'); + + const map = extractConstants(content); + assert.equal(map.get('Specs.Bold'), 'bold'); +}); + +test('resolveAllConstants expands a prefix into all known members', () => { + const content = ['enum NodeName { Para = "paragraph", Doc = "doc" }'].join('\n'); + const map = extractConstants(content); + + assert.deepEqual(resolveAllConstants(['NodeName'], map).sort(), ['doc', 'paragraph']); +}); diff --git a/scripts/docs/extractor/index.mjs b/scripts/docs/extractor/index.mjs index 54f24055..209380c9 100644 --- a/scripts/docs/extractor/index.mjs +++ b/scripts/docs/extractor/index.mjs @@ -117,11 +117,7 @@ export class ExtensionExtractor { for (const dir of listDirs(catDir)) { if (isInternalExtension(dir)) continue; const extDir = join(catDir, dir); - try { - extensions.push(this.scan(extDir, category)); - } catch (err) { - logger.warn(`failed to scan ${extDir}: ${err.message}`); - } + extensions.push(this.scan(extDir, category)); } } return extensions; @@ -150,7 +146,10 @@ export class ExtensionExtractor { logger.info(`Found ${extensions.length} extensions`); - writeFileSync(join(this.outDir, 'extensions.json'), JSON.stringify(extensions, null, 2)); + writeFileSync( + join(this.outDir, 'extensions.json'), + JSON.stringify({version, extensions}, null, 2), + ); for (const ext of extensions) { const rawMd = generateRawMd(ext, presetMap, version); diff --git a/scripts/docs/extractor/regex.mjs b/scripts/docs/extractor/regex.mjs index e48ab041..9e91cdab 100644 --- a/scripts/docs/extractor/regex.mjs +++ b/scripts/docs/extractor/regex.mjs @@ -67,16 +67,22 @@ export function extractMarkSpecs(content) { } /** - * Extracts action IDs from .addAction() calls + * Extracts action IDs from builder.addAction() calls (incl. chained .addAction(...).addAction(...)) * @param content */ export function extractActions(content) { const actions = []; - const re = /\.addAction\(\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])/g; + // builder.addAction(name, ...) — direct call + const re = /builder\s*\.addAction\(\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])/g; let m; while ((m = re.exec(content))) { actions.push(m[3] || m[1] || m[2]); } + // Chained: ).addAction(name, ...) — must follow another builder call (closing paren on previous line/inline) + const re2 = /\)\s*\.addAction\(\s*(?:(\w+\.\w+)|(\w+)|['"]([^'"]+)['"])/g; + while ((m = re2.exec(content))) { + actions.push(m[3] || m[1] || m[2]); + } return actions; } @@ -102,7 +108,7 @@ function skipWhitespace(content, index) { return cursor; } -function readBalanced(content, startIndex, openChar, closeChar) { +export function readBalanced(content, startIndex, openChar, closeChar) { let depth = 0; let quote = null; let inBlockComment = false; diff --git a/scripts/docs/extractor/regex.test.mjs b/scripts/docs/extractor/regex.test.mjs index 8e7d766e..eae9ad9b 100644 --- a/scripts/docs/extractor/regex.test.mjs +++ b/scripts/docs/extractor/regex.test.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {test} from 'node:test'; -import {extractKeymaps} from './regex.mjs'; +import {extractActions, extractKeymaps} from './regex.mjs'; function readRepoFile(relativePath) { return readFileSync(new URL(relativePath, import.meta.url), 'utf-8'); @@ -48,6 +48,27 @@ test('extractKeymaps captures static keymaps from the Lists extension', () => { ]); }); +test('extractActions captures direct and chained builder.addAction calls', () => { + const content = [ + "builder.addAction('bold', () => boldAction);", + 'builder', + ' .addAction(BoldAction.Toggle, () => toggle)', + ' .addAction(BoldAction.Off, () => off);', + ].join('\n'); + + assert.deepEqual(extractActions(content), ['bold', 'BoldAction.Toggle', 'BoldAction.Off']); +}); + +test('extractActions ignores non-builder addAction calls (tr, services)', () => { + const content = [ + "tr.addAction('shouldNotMatch', cb);", + "service.addAction('alsoSkip', cb);", + "builder.addAction('keepMe', cb);", + ].join('\n'); + + assert.deepEqual(extractActions(content), ['keepMe']); +}); + test('extractKeymaps captures static bindings from block-body callbacks and ignores dynamic ones', () => { const headingContent = readRepoFile( '../../../packages/editor/src/extensions/markdown/Heading/index.ts', diff --git a/scripts/docs/index.mjs b/scripts/docs/index.mjs index ce26b812..756304cc 100644 --- a/scripts/docs/index.mjs +++ b/scripts/docs/index.mjs @@ -18,7 +18,7 @@ const DOCS_GEN_DIR = 'docs-gen'; */ export function parseArgs(args = process.argv.slice(2)) { const command = args[0]; - const opts = {mode: 'prompts', only: null, model: null}; + const opts = {mode: 'prompts', only: null}; for (let i = 1; i < args.length; i++) { switch (args[i]) { @@ -28,9 +28,6 @@ export function parseArgs(args = process.argv.slice(2)) { case '--only': opts.only = args[++i]?.split(','); break; - case '--model': - opts.model = args[++i]; - break; } } @@ -51,7 +48,7 @@ export function createCommandHandlers(paths = {}) { new ExtensionExtractor(editorPkg, docsGenDir).run(); } - async function runEnrich(opts) { + function runEnrich(opts) { const enricher = new Enricher(docsGenDir); enricher.load(); @@ -60,18 +57,9 @@ export function createCommandHandlers(paths = {}) { const count = enricher.generatePrompts(opts); logger.success(`Generated ${count} prompt files in ${docsGenDir}/prompts/`); logger.info('\nNext steps:'); - logger.info(' - Process prompts through your AI tool'); + logger.info(' - Process prompts through your AI tool or agent'); logger.info(` - Save responses in ${docsGenDir}/responses/ExtName.json`); - logger.info(' - Run: node scripts/docs/index.mjs enrich --mode apply'); - logger.info('\nOr with OpenAI API:'); - logger.info( - ' OPENAI_API_KEY=sk-... node scripts/docs/index.mjs enrich --mode enrich', - ); - break; - } - case 'enrich': { - const count = await enricher.enrichWithAI(opts); - logger.success(`Enriched ${count} docs in ${docsGenDir}/enriched/`); + logger.info(' - Run: pnpm docs:enrich:apply'); break; } case 'apply': { @@ -80,9 +68,7 @@ export function createCommandHandlers(paths = {}) { break; } default: - throw new Error( - `Unknown enrich mode: ${opts.mode}. Use --mode prompts|enrich|apply`, - ); + throw new Error(`Unknown enrich mode: ${opts.mode}. Use --mode prompts|apply`); } } @@ -105,7 +91,7 @@ export function createCommandHandlers(paths = {}) { }; } -export async function main(args = process.argv.slice(2), paths = {}) { +export function main(args = process.argv.slice(2), paths = {}) { const {command, opts} = parseArgs(args); const commands = createCommandHandlers(paths); @@ -116,16 +102,18 @@ export async function main(args = process.argv.slice(2), paths = {}) { } if (command === 'enrich') { - await handler(opts); + handler(opts); return; } - await handler(); + handler(); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { - main().catch((err) => { + try { + main(); + } catch (err) { logger.error(err); process.exit(1); - }); + } }