From d44047d0923cfdbd561c10a1c915b67973b55781 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Thu, 27 Aug 2026 18:51:22 -0400 Subject: [PATCH 1/5] refactor: pf-4402 format markdown content --- cspell.config.json | 1 + .../tool.patternFlyDocs.test.ts.snap | 4 + src/__tests__/resource.helpers.test.ts | 341 ++++++++++++++- src/resource.helpers.ts | 413 +++++++++++++++++- src/resource.patternFlyDocsTemplate.ts | 3 +- src/tool.patternFlyDocs.ts | 3 +- 6 files changed, 761 insertions(+), 4 deletions(-) diff --git a/cspell.config.json b/cspell.config.json index b23ec354..027b244e 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -16,6 +16,7 @@ "onsessionclosed", "patternfly", "prefault", + "pypy", "rereview", "rescan", "rootfs", diff --git a/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap b/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap index 40b6ac1b..a589ea99 100644 --- a/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap +++ b/src/__tests__/__snapshots__/tool.patternFlyDocs.test.ts.snap @@ -21,7 +21,9 @@ exports[`usePatternFlyDocsTool, callback should have a specific markdown format: # Documentation for Button (v6) [Documentation] Source: components/loremButton.md +\`\`\` lorem documentation content +\`\`\` --- @@ -29,7 +31,9 @@ lorem documentation content # Content for components/ipsumButton.md Source: components/ipsumButton.md +\`\`\` ipsum documentation content +\`\`\` --- diff --git a/src/__tests__/resource.helpers.test.ts b/src/__tests__/resource.helpers.test.ts index 0031e2d1..455514c4 100644 --- a/src/__tests__/resource.helpers.test.ts +++ b/src/__tests__/resource.helpers.test.ts @@ -1,4 +1,18 @@ -import { paramCompletion } from '../resource.helpers'; +import { + contentType, + formatContentForMarkdown, + isCssLike, + isJavaLike, + isJsLike, + isJson, + isJsonLike, + isMarkdown, + isPythonLike, + isScriptLike, + isShellLike, + isXmlLike, + paramCompletion +} from '../resource.helpers'; import { filterPatternFly } from '../patternFly.search'; import { normalizeEnumeratedPatternFlyVersion } from '../patternFly.helpers'; @@ -15,6 +29,331 @@ jest.mock('../patternFly.helpers', () => ({ const MockFilter = filterPatternFly.memo as jest.MockedFunction; const MockNormalizeVersion = normalizeEnumeratedPatternFlyVersion.memo as jest.MockedFunction; +describe('isCssLike', () => { + it.each([ + { + description: 'selector with braces', input: 'button { color: red; }', expected: true + }, + { + description: '@media rule', input: '@media (max-width:600px) {}', expected: true + }, + { + description: 'property declaration only', input: 'color: #fff;', expected: false + }, + { + description: 'url usage', input: 'background-image:url("foo.png");', expected: false + }, + { + description: 'non‑CSS string', input: 'Hello world', expected: false + } + ])('should detect CSS‑like syntax, $description', ({ input, expected }) => { + expect(isCssLike(input)).toBe(expected); + }); +}); + +describe('isJson', () => { + it.each([ + { + description: 'valid JSON string', input: '{"a":1,"b":"x"}', expected: true + }, + { + description: 'object value', input: { a: 1 }, expected: true + }, + { + description: 'array value', input: [1, 2], expected: true + }, + { + description: 'empty array with allowEmpty=false', input: '[]', options: { allowEmpty: false }, expected: false + }, + { + description: 'empty object with allowEmpty=false', input: '{}', options: { allowEmpty: false }, expected: false + }, + { + description: 'empty array by default (allowEmpty=true)', input: '[]', expected: true + }, + { + description: 'empty object by default (allowEmpty=true)', input: '{}', expected: true + }, + { + description: 'non‑JSON string', input: 'not json', expected: false + }, + { + description: 'non‑JSON number', input: 42 as any, expected: false + } + ])('should validate JSON, $description', ({ input, options, expected }) => { + expect(isJson(input, options)).toBe(expected); + }); +}); + +describe('isJsonLike', () => { + it.each([ + { + description: 'object string', input: '{"a":1}', expected: true + }, + { + description: 'array string', input: '[1,2]', expected: true + }, + { + description: 'real object', input: { a: 1 }, expected: true + }, + { + description: 'real array', input: [1, 2], expected: true + }, + { + description: 'missing quotes', input: '{a:1}', expected: true + }, + { + description: 'non‑JSON string', input: 'hello', expected: false + }, + { + description: 'non‑JSON number', input: 42 as any, expected: false + } + ])('should detect JSON‑like values, $description', ({ input, expected }) => { + expect(isJsonLike(input)).toBe(expected); + }); +}); + +describe('isMarkdown', () => { + it.each([ + { + description: 'heading', input: '# Title', expected: true + }, + { + description: 'blockquote', input: '> Quote', expected: true + }, + { + description: 'unordered list', input: '- item', expected: true + }, + { + description: 'ordered list', input: '1. first', expected: true + }, + { + description: 'link', input: '[Google](https://google.com)', expected: true + }, + { + description: 'image', input: '![alt](img.png)', expected: true + }, + { + description: 'fenced code block', input: '```js\nconst a=1;\n```', expected: true + }, + { + description: 'table', input: '| Header |\n|--------|\n| Cell |', expected: false + }, + { + description: 'plain text', input: 'Just a sentence.', expected: false + } + ])('should detect markdown, $description', ({ input, expected }) => { + expect(isMarkdown(input)).toBe(expected); + }); +}); + +describe('isXmlLike', () => { + it.each([ + { + description: 'HTML document', input: `Hello`, expected: true + }, + { + description: 'SVG document', input: ``, expected: true + }, + { + description: 'mismatched tags', input: '
', expected: true + }, + { + description: 'non‑XML code', input: 'console.log("hi")', expected: false + } + ])('should detect XML/HTML, $description', ({ input, expected }) => { + expect(isXmlLike(input)).toBe(expected); + }); +}); + +describe('isJavaLike', () => { + it.each([ + { + description: 'class declaration', input: `public class Foo { }`, expected: true + }, + { + description: 'package statement', input: `package com.example;`, expected: true + }, + { + description: 'main method', + input: ` + public static void main(String[] args) { + System.out.println("hi"); + } + `, + expected: true + }, + { + description: 'non‑Java code', input: 'console.log("hi")', expected: false + } + ])('should detect Java‑like code, $description', ({ input, expected }) => { + expect(isJavaLike(input)).toBe(expected); + }); +}); + +describe('isJsLike', () => { + it.each([ + { + description: 'shebang', input: `#!/usr/bin/env node`, expected: true + }, + { + description: 'ESM import/export', input: `import foo from 'bar'; export default foo;`, expected: true + }, + { + description: 'CommonJS module.exports', input: `module.exports = function(){}`, expected: true + }, + { + description: 'TypeScript interface', input: `interface Foo { bar: string }`, expected: true + }, + { + description: 'React hook + JSX', input: `const Comp = () => { useEffect(()=>{},[]); return
};`, expected: true + }, + { + description: 'plain text', input: 'Hello world', expected: false + } + ])('should detect JS/TS/JSX code, $description', ({ input, expected }) => { + expect(isJsLike(input)).toBe(expected); + }); +}); + +describe('isPythonLike', () => { + it.each([ + { description: 'shebang', input: `#!/usr/bin/env python`, expected: true }, + { description: 'function definition', input: `def foo(x): return x*2`, expected: true }, + { description: 'class definition', input: `class Bar: pass`, expected: true }, + { + description: 'if __name__ block', + input: ` + if __name__ == "__main__": + print("run") + `, + expected: true + }, + { description: 'non‑Python code', input: `console.log("hi")`, expected: false } + ])('should detects python like scripts, $description', ({ input, expected }) => { + expect(isPythonLike(input)).toBe(expected); + }); +}); + +describe('isShellLike', () => { + it.each([ + { + description: 'shebang', input: `#!/usr/bin/env bash`, expected: true + }, + { + description: 'env variable export', input: `export PATH=/foo:$PATH`, expected: true + }, + { + description: 'control flow block', input: `if [[ $x -gt 0 ]]; then echo hi; fi`, expected: true + }, + { + description: 'function definition', input: `myfunc() { echo "ok"; }`, expected: true + }, + { + description: 'non‑shell code', input: `print("hi")`, expected: false + } + ])('should detect shell like scripts, $description', ({ input, expected }) => { + expect(isShellLike(input)).toBe(expected); + }); +}); + +describe('isScriptLike', () => { + it.each([ + { + description: 'Java', input: 'protected class X{}', expected: true + }, + { + description: 'JS', input: `console.log('hi'); module.exports=test`, expected: true + }, + { + description: 'Python', input: `def f(): pass`, expected: true + }, + { + description: 'Shell', input: `#! bash echo hi;`, expected: true + }, + { + description: 'XML', input: ``, expected: false + }, + { + description: 'CSS', input: `body{margin:0;}`, expected: false + }, + { + description: 'Markdown', input: `# Title`, expected: false + }, + { + description: 'JSON', input: '{"a":1}', expected: false + } + ])('should detect script content, $description', ({ input, expected }) => { + expect(isScriptLike(input)).toBe(expected); + }); +}); + +describe('contentType', () => { + it.each([ + { + description: 'markdown', input: '# Title', expected: 'markdown' + }, + { + description: 'json string', input: '{"a":1}', expected: 'json' + }, + { + description: 'xml/html', input: '
', expected: 'html' + }, + { + description: 'javascript', input: `console.log(42); module.exports=test`, expected: 'javascript' + }, + { + description: 'shell', input: `#!/bin/bash\necho hi`, expected: 'sh' + }, + { + description: 'python', input: `def f(): pass`, expected: 'python' + }, + { + description: 'java', input: `public class X{}`, expected: 'java' + }, + { + description: 'css', input: `.foo{}`, expected: 'css' + } + ])('should detect, $description', ({ input, expected }) => { + expect(contentType(input)).toBe(expected); + }); + + it('should return empty strings for null/empty values', () => { + expect(contentType(null as any)).toBe(''); + expect(contentType(undefined as any)).toBe(''); + expect(contentType('').trim()).toBe(''); + }); +}); + +describe('formatContentForMarkdown', () => { + const json = '{"a":1,"b":[2,3]}'; + const js = `cons` + `ole.log(42); module.exports=test`; + + it('should wrap non‑markdown content in a code block', () => { + expect(formatContentForMarkdown(js)).toMatch(/^```javascript\n/); + }); + + it('should pretty‑print JSON when language is JSON', () => { + const formatted = formatContentForMarkdown(json, { langOverride: 'json' }); + + expect(formatted).toContain('\n{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n'); + }); + + it('should not wrap markdown unless overridden', () => { + const md = '# Title'; + + expect(formatContentForMarkdown(md)).toBe('# Title'); // no wrapping + expect(formatContentForMarkdown(md, { langOverride: 'js' })).toMatch(/^```js\n/); + }); + + it('should wrap markdown with the allowWrappingMarkdown flag', () => { + const md = '- item'; + + expect(formatContentForMarkdown(md, { allowWrappingMarkdown: true })) + .toMatch(/^```markdown\n- item\n```$/); // wrapped as plain code + }); +}); + describe('paramCompletion', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/src/resource.helpers.ts b/src/resource.helpers.ts index 670fa081..65ff2de2 100644 --- a/src/resource.helpers.ts +++ b/src/resource.helpers.ts @@ -1,5 +1,402 @@ import { filterPatternFly, type FilterPatternFlyFilters } from './patternFly.search'; import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; +import { isPlainObject } from './server.helpers'; + +/** + * Is content CSS-like? + * + * CSS matching: + * - Selector or `@` followed by an opening brace + * - Common `@` rules. (e.g., `@media`, `@keyframes`, `@import`) + * - Property declarations (e.g., `color: red;`) + * - URL usage (e.g., `url(some-url)`) + * + * @param content - Input value + * @returns Returns `true` if the input matches CSS-like syntax. + */ +const isCssLike = (content: unknown): boolean => { + if (typeof content !== 'string') { + return false; + } + + const trimmed = content.trim(); + + const patterns = [ + /[.#&][\w-]+\s*\{/, // .class, #id, &nesting + /@(media|keyframes|import|mixin|include)\b/i, // @rules / directives + /(\$|@|--)[a-zA-Z_-][\w-]*\s*:/, // Sass, Less, CSS variables + /\b(html|body|div|span|p|a)\s*\{/i, // common tag selectors + /\{\s*[\w-]+\s*:\s*[^;{}]+;?\s*}/ // declaration blocks { prop: val; } + ]; + + return patterns.some(re => re.test(trimmed)); +}; + +/** + * Is a value JSON? + * + * @param content - Input value + * @param options - Options + * @param options.allowEmpty - Allow empty JSON objects/arrays as valid JSON. + * @returns Return `true` if parsed and non‑empty. + */ +const isJson = (content: unknown, { allowEmpty = true }: { allowEmpty?: boolean } = {}): boolean => { + try { + const parsed = typeof content === 'string' ? JSON.parse(content.trim()) : content; + + if (Array.isArray(parsed)) { + return allowEmpty ? true : parsed.length > 0; + } + if (isPlainObject(parsed)) { + return allowEmpty ? true : Object.keys(parsed).length > 0; + } + + return false; + } catch { + return false; + } +}; + +/** + * Simple is JSON-like guard. + * + * @param content - Input value + * @returns Return `true` if starts, ends with braces/brackets, is an Array or Object. + */ +const isJsonLike = (content: unknown): boolean => { + if (typeof content === 'string') { + const trimmed = content.trim(); + + return ( + (trimmed.startsWith('{') && trimmed.endsWith('}')) || + (trimmed.startsWith('[') && trimmed.endsWith(']')) + ); + } + + return Array.isArray(content) || isPlainObject(content); +}; + +/** + * Is content a Markdown-formatted string? + * + * Markdown patterns: + * - Headings (e.g., `# Heading`) + * - Blockquote (e.g., `> Blockquote`) + * - Unordered lists (e.g., `- Item`, `+ Item`, `* Item`) + * - Ordered lists (e.g., `1. Item`, `2. Item`) + * - Inline links (e.g., `[link](url)`) + * - Images (e.g., `![alt text](url)`) + * - Fenced code blocks + * + * @param content - Input value. + * @returns Returns `true` if the input matches "common" Markdown patterns. + */ +const isMarkdown = (content: unknown): boolean => { + if (typeof content !== 'string') { + return false; + } + + const patterns = [ + /^(#+\s)/m, // headings + /^>\s/m, // blockquote + /^[-+*]\s/m, // unordered list + /^\d+\.\s/m, // ordered list + /\[.*\]\(.*\)/, // inline link + /!\[.*\]\(.*\)/, // image + /^```/m, // fenced code block + /^\s*\|(?:\s*:-+:-*\s*\|)+\s*$/m // table + ]; + + return patterns.some(re => re.test(content)); +}; + +/** + * Is content XML-like? + * + * XML matching: + * - Start with an opening tag? + * - Contains a corresponding closing tag? + * - Matches common patterns in XML-like content. (e.g., HTML, SVG) + * + * @param content - Input value + * @returns Returns `true` if the content is XML-like + */ +const isXmlLike = (content: unknown): boolean => { + if (typeof content !== 'string') { + return false; + } + const trimmed = content.trim(); + + // Must start with a tag and contain a closing tag + if (!/^<\s*\w+/.test(trimmed) || !/<\/\s*\w+\s*>/.test(trimmed)) { + return false; + } + + const indicators = [ + //i, + / re.test(trimmed)); +}; + +/** + * Is content Java-like? + * + * Matching: + * - Classes + * - Package declarations + * - Entry points + * + * @param content - Input value + * @returns Returns `true` if the content is Java-like + */ +const isJavaLike = (content: unknown): boolean => { + if (typeof content !== 'string') { + return false; + } + const trimmed = content.trim(); + + const indicators = [ + /^\s*(public|private|protected)\s+(class|interface|enum|record)\s+\w+/m, // Class structure + /^\s*package\s+[a-z0-9_]+(\.[a-z0-9_]+)*\s*;/m, // Package declarations + /\b(public\s+static\s+void\s+main|System\.out\.print(ln)?)\b/ // Standard entry points + ]; + + const hasKeywords = () => /\b(system\.exit|yield|system\.out\.print)\b/i.test(trimmed); + const structuralSymbols = () => (trimmed.match(/[{};=>]/g) || []).length > 2; + + return indicators.some(re => re.test(trimmed)) || (hasKeywords() && structuralSymbols()); +}; + +/** + * Is content JS-like? + * + * Matching: + * - shebangs + * - ESM + * - CommonJS + * - TS + * - React + * - JSX + * + * @param content - Input value + * @returns Returns `true` if the content is JS-like + */ +const isJsLike = (content: unknown): boolean => { + if (typeof content !== 'string') { + return false; + } + const trimmed = content.trim(); + + const indicators = [ + /#!\s*.*\b(node|deno|bun)\b/, // shebangs + /^\s*(import\s+([\w\s{},*]+|['"].+['"])\s+from\s+['"].+['"]|export\s+(default\s+)?(const|let|function|class|interface|type))/m, // ESM + /\b(module\.exports\s*=|exports\.\w+\s*=|=\s*require\(['"].+['"]\))/, // CommonJS + /^\s*(interface|type)\s+[A-Z]\w*\s*[{=]/m, // TS + /\b(useState|useEffect|useContext|useRef|useMemo|useCallback)\(/, // React + /return\s*\(\s*<[A-Za-z0-9_$.]+[^>]*>/ // React/JSX + ]; + + const hasKeywords = () => /\b(console\.log|process\.exit)\b/.test(trimmed); + const structuralSymbols = () => (trimmed.match(/[{};=>]/g) || []).length > 2; + + return indicators.some(re => re.test(trimmed)) || (hasKeywords() && structuralSymbols()); +}; + +/** + * Is content Python-like? + * + * Matching: + * - shebangs + * - Function/classes + * - Main block entry point + * - Native imports + * + * @param content - Input value + * @returns Returns `true` if the content is Python-like + */ +const isPythonLike = (content: unknown): boolean => { + if (typeof content !== 'string') { + return false; + } + const trimmed = content.trim(); + + const indicators = [ + /#!\s*.*\b(python|pypy)\b/, // shebangs + /^\s*(def|class)\s+[a-zA-Z_]\w*\s*(\(.*?\))?\s*:/m, // Function/class definitions + /^\s*if\s+__name__\s*==\s*['"]__main__['"]\s*:/m, // Main block entry point + /^\s*(import\s+[a-zA-Z_]\w*|from\s+[a-zA-Z_]\w*\s+import)/m // Native imports + ]; + + const hasKeywords = () => /\b(sys\.exit|print)\b/.test(trimmed); + const structuralSymbols = () => (trimmed.match(/[{};=>]/g) || []).length > 2; + + return indicators.some(re => re.test(trimmed)) || (hasKeywords() && structuralSymbols()); +}; + +/** + * Is content Shell-like? + * + * Matching: + * - shebangs + * - Function/classes + * - Main block entry point + * - Native imports + * + * @param content - Input value + * @returns Returns `true` if the content is Shell-like + */ +const isShellLike = (content: unknown): boolean => { + if (typeof content !== 'string') { + return false; + } + const trimmed = content.trim(); + + const indicators = [ + /#!\s*.*\b(bash|sh|zsh)\b/, // shebangs + /^\s*(unset\s+\w+|export\s+\w+=|local\s+\w+=)/m, // Env vars + /^\s*(if\s+\[\[|case\s+.*?\s+in|for\s+\w+\s+in\s+)/m, // Shell control blocks + /^\s*[a-zA-Z_]\w*\s*\(\s*\)\s*\{/m // Shell functions: name() { + ]; + + const hasKeywords = () => /\b(echo|printf)\b/.test(trimmed); + const structuralSymbols = () => (trimmed.match(/[{};=>]/g) || []).length > 2; + + return indicators.some(re => re.test(trimmed)) || (hasKeywords() && structuralSymbols()); +}; + +/** + * Is content script-like? + * + * Script matching: + * - Shebangs + * - Bash/Shell + * - Python + * - Java + * - JS/TS/JSX/TSX + * - Common statements across JS, TS, Java, and Python + * + * @param content - Input value + * @returns Return `true` if content is script-like + */ +const isScriptLike = (content: unknown): boolean => { + if (!content || typeof content !== 'string') { + return false; + } + + const trimmed = content.trim(); + + if (!trimmed) { + return false; + } + + if ( + isXmlLike(trimmed) || + isCssLike(trimmed) || + isMarkdown(trimmed) || + isJson(trimmed) || + isJsonLike(trimmed) + ) { + return false; + } + + return isJavaLike(trimmed) || isJsLike(trimmed) || isPythonLike(trimmed) || isShellLike(trimmed); +}; + +/** + * Determine the "type" of content based on its structure and formatting. + + * Content type identifiers: + * - See {@link isMarkdown} + * - See {@link isJsonLike} and {@link isJson} + * - See {@link isCssLike} + * - See {@link isXmlLike} + * - See {@link isPythonLike} + * - See {@link isShellLike} + * - See {@link isJavaLike} + * - See {@link isJsLike} + * + * @param content - Input value. + * @returns A type of content string, or empty if the content type can't be determined. + */ +const contentType = (content: unknown): '' | 'sh' | 'python' | 'markdown' | 'java' | 'javascript' | 'json' | 'html' | 'css' => { + const updatedLanguage = ''; + + if (content === null || content === undefined || (typeof content === 'string' && content.trim().length <= 0)) { + return ''; + } + + if (isMarkdown(content as string)) { + return 'markdown'; + } + + if (isJsonLike(content) || isJson(content)) { + return 'json'; + } + + if (isXmlLike(content)) { + return 'html'; + } + + if (isJsLike(content)) { + return 'javascript'; + } + + if (isShellLike(content)) { + return 'sh'; + } + + if (isPythonLike(content)) { + return 'python'; + } + + if (isJavaLike(content)) { + return 'java'; + } + + if (isCssLike(content)) { + return 'css'; + } + + return updatedLanguage; +}; + +/** + * Format content as a code block for Markdown rendering. + * + * @param content - Content to format. + * @param options - Config options for formatting. + * @param [options.langOverride] - Override the detected language for highlighting. + * @param [options.allowWrappingMarkdown=false] - Determine if already-marked Markdown content should be forcefully wrapped. + * @returns A formatted content string wrapped in a Markdown code block, or the original content. + */ +const formatContentForMarkdown = ( + content: unknown, + { langOverride, allowWrappingMarkdown = false }: { langOverride?: string; allowWrappingMarkdown?: boolean } = {} +) => { + const updatedLanguage = langOverride || contentType(content); + let updatedContent = content; + + if (!allowWrappingMarkdown && isMarkdown(updatedContent) && (!langOverride || updatedLanguage === 'markdown')) { + return updatedContent; + } + + if (updatedLanguage === 'json') { + try { + const parsed = typeof updatedContent === 'string' ? JSON.parse(updatedContent.trim()) : updatedContent; + + updatedContent = JSON.stringify(parsed, null, 2); + } catch {} + } + + return `\`\`\`${updatedLanguage}\n${updatedContent}\n\`\`\``; +}; /** * Centralized completion logic for PatternFly resources. @@ -47,4 +444,18 @@ const paramCompletion = async (filters: FilterPatternFlyFilters) => { }; }; -export { paramCompletion }; +export { + contentType, + formatContentForMarkdown, + isJavaLike, + isJsLike, + isJson, + isJsonLike, + isCssLike, + isMarkdown, + isPythonLike, + isScriptLike, + isShellLike, + isXmlLike, + paramCompletion +}; diff --git a/src/resource.patternFlyDocsTemplate.ts b/src/resource.patternFlyDocsTemplate.ts index 9b8b922e..4d4fda88 100644 --- a/src/resource.patternFlyDocsTemplate.ts +++ b/src/resource.patternFlyDocsTemplate.ts @@ -14,6 +14,7 @@ import { uriSectionComplete, uriVersionComplete } from './resource.patternFlyDocsIndex'; +import { formatContentForMarkdown } from './resource.helpers'; /** * Name of the resource template. @@ -180,7 +181,7 @@ const resourceCallback = async (passedUri: URL, variables: Record`, `# Documentation for ${displayName} - ${displayCategory} (${entryVersion})`, '', - content + formatContentForMarkdown(content) ) })) }; diff --git a/src/tool.patternFlyDocs.ts b/src/tool.patternFlyDocs.ts index e3d3f75f..9efa7cd9 100644 --- a/src/tool.patternFlyDocs.ts +++ b/src/tool.patternFlyDocs.ts @@ -15,6 +15,7 @@ import { searchPatternFly, type SearchPatternFlyResult } from './patternFly.sear import { getPatternFlyMcpResources, getPatternFlyComponentSchema, setCategoryDisplayLabel } from './patternFly.getResources'; import { normalizeEnumeratedPatternFlyVersion } from './patternFly.helpers'; import { isPatternFlyUri } from './patternFly.support'; +import { formatContentForMarkdown } from './resource.helpers'; /** * usePatternFlyDocs tool function @@ -206,7 +207,7 @@ const usePatternFlyDocsTool = (options = getOptions()): McpTool => { docTitle, `Source: ${doc.path}`, '', - doc.content + formatContentForMarkdown(doc.content) )); if (latestSchemasVersion === entryVersion && entryName) { From f693416c513e6bee7f6440acb747439b17cf1ee4 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 31 Aug 2026 13:55:54 -0400 Subject: [PATCH 2/5] fix: review update --- src/__tests__/resource.helpers.test.ts | 5 ++++- src/resource.helpers.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/__tests__/resource.helpers.test.ts b/src/__tests__/resource.helpers.test.ts index 455514c4..31a5598b 100644 --- a/src/__tests__/resource.helpers.test.ts +++ b/src/__tests__/resource.helpers.test.ts @@ -137,7 +137,10 @@ describe('isMarkdown', () => { description: 'fenced code block', input: '```js\nconst a=1;\n```', expected: true }, { - description: 'table', input: '| Header |\n|--------|\n| Cell |', expected: false + description: 'table', input: '| Header |\n|--------|\n| Cell |', expected: true + }, + { + description: 'table with colon', input: '| Header |\n|:---|:---|\n| Cell |', expected: true }, { description: 'plain text', input: 'Just a sentence.', expected: false diff --git a/src/resource.helpers.ts b/src/resource.helpers.ts index 65ff2de2..24ba4740 100644 --- a/src/resource.helpers.ts +++ b/src/resource.helpers.ts @@ -104,7 +104,8 @@ const isMarkdown = (content: unknown): boolean => { /\[.*\]\(.*\)/, // inline link /!\[.*\]\(.*\)/, // image /^```/m, // fenced code block - /^\s*\|(?:\s*:-+:-*\s*\|)+\s*$/m // table + // /^\s*\|(?:\s*:-+:-*\s*\|)+\s*$/m // table + /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/m // table ]; return patterns.some(re => re.test(content)); From 97ecd97ba5e0e977aff346c2a0bce03f057cf147 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 31 Aug 2026 14:03:16 -0400 Subject: [PATCH 3/5] fix: review update --- src/__tests__/resource.helpers.test.ts | 5 ++++- src/resource.helpers.ts | 12 ++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/__tests__/resource.helpers.test.ts b/src/__tests__/resource.helpers.test.ts index 31a5598b..3bd4713e 100644 --- a/src/__tests__/resource.helpers.test.ts +++ b/src/__tests__/resource.helpers.test.ts @@ -144,6 +144,9 @@ describe('isMarkdown', () => { }, { description: 'plain text', input: 'Just a sentence.', expected: false + }, + { + description: 'unrelated brackets and parentheses', input: '[1] foo (bar) [2] (baz)', expected: false } ])('should detect markdown, $description', ({ input, expected }) => { expect(isMarkdown(input)).toBe(expected); @@ -233,7 +236,7 @@ describe('isPythonLike', () => { expected: true }, { description: 'non‑Python code', input: `console.log("hi")`, expected: false } - ])('should detects python like scripts, $description', ({ input, expected }) => { + ])('should detect Python-like scripts, $description', ({ input, expected }) => { expect(isPythonLike(input)).toBe(expected); }); }); diff --git a/src/resource.helpers.ts b/src/resource.helpers.ts index 24ba4740..93ec1426 100644 --- a/src/resource.helpers.ts +++ b/src/resource.helpers.ts @@ -7,9 +7,10 @@ import { isPlainObject } from './server.helpers'; * * CSS matching: * - Selector or `@` followed by an opening brace - * - Common `@` rules. (e.g., `@media`, `@keyframes`, `@import`) - * - Property declarations (e.g., `color: red;`) - * - URL usage (e.g., `url(some-url)`) + * - Common `@` rules (e.g., `@media`, `@keyframes`, `@import`) + * - Sass, Less, and CSS variable declarations (e.g., `--color: red;`) + * - Common HTML tag selectors (e.g., `body {`) + * - Declaration blocks (e.g., `{ color: red; }`) * * @param content - Input value * @returns Returns `true` if the input matches CSS-like syntax. @@ -101,10 +102,9 @@ const isMarkdown = (content: unknown): boolean => { /^>\s/m, // blockquote /^[-+*]\s/m, // unordered list /^\d+\.\s/m, // ordered list - /\[.*\]\(.*\)/, // inline link - /!\[.*\]\(.*\)/, // image + /\[[^\]]+\]\([^)]+\)/, // inline link + /!\[[^\]]*\]\([^)]+\)/, // image /^```/m, // fenced code block - // /^\s*\|(?:\s*:-+:-*\s*\|)+\s*$/m // table /^\s*\|(?:\s*:?-+:?\s*\|)+\s*$/m // table ]; From afb33c8f8179d7ab9456942dec28ef50ba837a22 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 31 Aug 2026 14:43:01 -0400 Subject: [PATCH 4/5] fix: review update --- src/__tests__/resource.helpers.test.ts | 122 ++++++++++++++++++++----- src/resource.helpers.ts | 38 ++++++-- 2 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/__tests__/resource.helpers.test.ts b/src/__tests__/resource.helpers.test.ts index 3bd4713e..a6ad9bb3 100644 --- a/src/__tests__/resource.helpers.test.ts +++ b/src/__tests__/resource.helpers.test.ts @@ -102,6 +102,15 @@ describe('isJsonLike', () => { { description: 'missing quotes', input: '{a:1}', expected: true }, + { + description: 'empty object', input: '{}', expected: true + }, + { + description: 'empty array', input: '[]', expected: true + }, + { + description: 'relaxed JSON-like with unquoted keys', input: '{ a: 1, b: "two" }', expected: true + }, { description: 'non‑JSON string', input: 'hello', expected: false }, @@ -142,6 +151,15 @@ describe('isMarkdown', () => { { description: 'table with colon', input: '| Header |\n|:---|:---|\n| Cell |', expected: true }, + { + description: 'shebang bash script with comments', input: '#!/usr/bin/env bash\n# Heading comment\necho 1', expected: false + }, + { + description: 'shebang python script with comments', input: '#!/usr/bin/env python\n# Heading comment\nprint(1)', expected: false + }, + { + description: 'seven hashes heading limit', input: '####### Not a heading', expected: false + }, { description: 'plain text', input: 'Just a sentence.', expected: false }, @@ -155,6 +173,9 @@ describe('isMarkdown', () => { describe('isXmlLike', () => { it.each([ + { + description: 'HTML document with DOCTYPE', input: `Hello`, expected: true + }, { description: 'HTML document', input: `Hello`, expected: true }, @@ -332,31 +353,82 @@ describe('contentType', () => { }); describe('formatContentForMarkdown', () => { - const json = '{"a":1,"b":[2,3]}'; - const js = `cons` + `ole.log(42); module.exports=test`; - - it('should wrap non‑markdown content in a code block', () => { - expect(formatContentForMarkdown(js)).toMatch(/^```javascript\n/); - }); - - it('should pretty‑print JSON when language is JSON', () => { - const formatted = formatContentForMarkdown(json, { langOverride: 'json' }); - - expect(formatted).toContain('\n{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n'); - }); - - it('should not wrap markdown unless overridden', () => { - const md = '# Title'; - - expect(formatContentForMarkdown(md)).toBe('# Title'); // no wrapping - expect(formatContentForMarkdown(md, { langOverride: 'js' })).toMatch(/^```js\n/); - }); - - it('should wrap markdown with the allowWrappingMarkdown flag', () => { - const md = '- item'; - - expect(formatContentForMarkdown(md, { allowWrappingMarkdown: true })) - .toMatch(/^```markdown\n- item\n```$/); // wrapped as plain code + it.each([ + { + description: 'wrap non‑markdown content in block', + input: `console.log(42); module.exports=test`, + expected: '```javascript\nconsole.log(42); module.exports=test\n```' + }, + { + description: 'pretty‑print JSON', + input: '{"a":1,"b":[2,3]}', + options: { langOverride: 'json' }, + expected: '```json\n{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n```' + }, + { + description: 'not wrap markdown unless overridden', + input: '# Title', + expected: '# Title' + }, + { + description: 'wrap markdown when overridden', + input: '# Title', + options: { langOverride: 'js' }, + expected: '```js\n# Title\n```' + }, + { + description: 'wrap markdown with allowWrappingMarkdown flag', + input: '- item', + options: { allowWrappingMarkdown: true }, + expected: '```markdown\n- item\n```' + }, + { + description: 'null value', + input: null, + expected: '```\nnull\n```' + }, + { + description: 'undefined value', + input: undefined, + expected: '```\nundefined\n```' + }, + { + description: 'empty string value', + input: '', + expected: '```\n\n```' + }, + { + description: 'whitespace value', + input: ' ', + expected: '```\n \n```' + }, + { + description: 'string null', + input: 'null', + expected: '```\nnull\n```' + }, + { + description: 'string undefined', + input: 'undefined', + expected: '```\nundefined\n```' + }, + { + description: 'string NaN', + input: 'NaN', + expected: '```\nNaN\n```' + }, + { + description: 'number 0', + input: 0, + expected: '```\n0\n```' + }, + { + description: 'number NaN', + input: NaN, + expected: '```\nNaN\n```' + } + ])('should format content for markdown, $description', ({ input, options, expected }) => { + expect(formatContentForMarkdown(input, options)).toBe(expected); }); }); diff --git a/src/resource.helpers.ts b/src/resource.helpers.ts index 93ec1426..c5597071 100644 --- a/src/resource.helpers.ts +++ b/src/resource.helpers.ts @@ -68,10 +68,27 @@ const isJsonLike = (content: unknown): boolean => { if (typeof content === 'string') { const trimmed = content.trim(); - return ( + if ( (trimmed.startsWith('{') && trimmed.endsWith('}')) || (trimmed.startsWith('[') && trimmed.endsWith(']')) - ); + ) { + const inner = trimmed.slice(1, -1).trim(); + + // Empty object or array + if (!inner) { + return true; + } + + const patterns = [ + /(['"])?[\w$-]+\1?\s*:/, // key: value, "key": value + /(['"]).*\1/, // quoted strings + /\b(true|false|null)\b/, // primitives + /\b\d+(\.\d+)?\b/, // numbers + /,/ // comma-separated items + ]; + + return patterns.some(re => re.test(inner)); + } } return Array.isArray(content) || isPlainObject(content); @@ -97,8 +114,14 @@ const isMarkdown = (content: unknown): boolean => { return false; } + const trimmed = content.trim(); + + if (/^#!\s*\S+/.test(trimmed)) { + return false; + } + const patterns = [ - /^(#+\s)/m, // headings + /^(#{1,6}\s)/m, // headings /^>\s/m, // blockquote /^[-+*]\s/m, // unordered list /^\d+\.\s/m, // ordered list @@ -129,7 +152,7 @@ const isXmlLike = (content: unknown): boolean => { const trimmed = content.trim(); // Must start with a tag and contain a closing tag - if (!/^<\s*\w+/.test(trimmed) || !/<\/\s*\w+\s*>/.test(trimmed)) { + if (!/^<\s*[!?\w]/i.test(trimmed) || !/<\/\s*\w+\s*>/.test(trimmed)) { return false; } @@ -236,7 +259,7 @@ const isPythonLike = (content: unknown): boolean => { ]; const hasKeywords = () => /\b(sys\.exit|print)\b/.test(trimmed); - const structuralSymbols = () => (trimmed.match(/[{};=>]/g) || []).length > 2; + const structuralSymbols = () => (trimmed.match(/[:=()]/g) || []).length > 2; return indicators.some(re => re.test(trimmed)) || (hasKeywords() && structuralSymbols()); }; @@ -267,7 +290,7 @@ const isShellLike = (content: unknown): boolean => { ]; const hasKeywords = () => /\b(echo|printf)\b/.test(trimmed); - const structuralSymbols = () => (trimmed.match(/[{};=>]/g) || []).length > 2; + const structuralSymbols = () => (trimmed.match(/[{}[\]$;|&><=]/g) || []).length > 2; return indicators.some(re => re.test(trimmed)) || (hasKeywords() && structuralSymbols()); }; @@ -371,6 +394,9 @@ const contentType = (content: unknown): '' | 'sh' | 'python' | 'markdown' | 'jav /** * Format content as a code block for Markdown rendering. * + * @note We purposefully allow passing in `null`, `undefined`, and empty strings since + * that may be the content the consumer is attempting to render. + * * @param content - Content to format. * @param options - Config options for formatting. * @param [options.langOverride] - Override the detected language for highlighting. From 819ec729d12fc5c3784fdf5a6416948771ffaef3 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 31 Aug 2026 15:53:09 -0400 Subject: [PATCH 5/5] fix: review update --- src/__tests__/resource.helpers.test.ts | 42 ++++++++++++++++++++++++++ src/resource.helpers.ts | 23 +++++++++++--- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/src/__tests__/resource.helpers.test.ts b/src/__tests__/resource.helpers.test.ts index a6ad9bb3..3e0e77ff 100644 --- a/src/__tests__/resource.helpers.test.ts +++ b/src/__tests__/resource.helpers.test.ts @@ -185,6 +185,30 @@ describe('isXmlLike', () => { { description: 'mismatched tags', input: '
', expected: true }, + { + description: 'standalone tag', input: '', expected: true + }, + { + description: 'standalone tag without spaces', input: '', expected: true + }, + { + description: 'standalone tag with attributes', input: '', expected: true + }, + { + description: 'standalone hyphenated custom tag', input: '', expected: true + }, + { + description: 'standalone namespaced tag', input: '', expected: true + }, + { + description: 'standalone void HTML tag', input: 'avatar', expected: true + }, + { + description: 'XML declaration', input: '', expected: true + }, + { + description: 'generic paired XML tags', input: 'value', expected: true + }, { description: 'non‑XML code', input: 'console.log("hi")', expected: false } @@ -323,9 +347,21 @@ describe('contentType', () => { { description: 'json string', input: '{"a":1}', expected: 'json' }, + { + description: 'valid json object string', input: '{"color": "red"}', expected: 'json' + }, + { + description: 'valid json array string', input: '[1, 2, 3]', expected: 'json' + }, { description: 'xml/html', input: '
', expected: 'html' }, + { + description: 'standalone xml tag', input: '', expected: 'html' + }, + { + description: 'standalone custom tag with attributes', input: '', expected: 'html' + }, { description: 'javascript', input: `console.log(42); module.exports=test`, expected: 'javascript' }, @@ -340,6 +376,12 @@ describe('contentType', () => { }, { description: 'css', input: `.foo{}`, expected: 'css' + }, + { + description: 'css declaration block', input: '{ color: red; }', expected: 'css' + }, + { + description: 'css custom property', input: '--pf-v6-global--Color: #fff;', expected: 'css' } ])('should detect, $description', ({ input, expected }) => { expect(contentType(input)).toBe(expected); diff --git a/src/resource.helpers.ts b/src/resource.helpers.ts index c5597071..67f2ddff 100644 --- a/src/resource.helpers.ts +++ b/src/resource.helpers.ts @@ -151,13 +151,24 @@ const isXmlLike = (content: unknown): boolean => { } const trimmed = content.trim(); - // Must start with a tag and contain a closing tag - if (!/^<\s*[!?\w]/i.test(trimmed) || !/<\/\s*\w+\s*>/.test(trimmed)) { + // Must start with a tag + if (!/^<\s*[!?\w]/i.test(trimmed)) { return false; } + // Standalone tags + if (/^<\s*([a-zA-Z][\w:-]*)(?:\s+[^>]*)?\s*\/>$/s.test(trimmed) || /<\s*[a-zA-Z][\w:-]*(?:\s+[^>]*)?\s*\/>/.test(trimmed)) { + return true; + } + + // Paired tags + if (/<\/\s*[\w:-]+\s*>/.test(trimmed) && /^<\s*[a-zA-Z][\w:-]*/.test(trimmed)) { + return true; + } + const indicators = [ - //i, + /]+>/i, + /<\?xml\b/i, /