Merge pull request #2 from LukeHagar/formatting-markdown

Formatting the GFM
This commit is contained in:
Luke Hagar
2025-11-10 12:24:16 -06:00
committed by GitHub
9 changed files with 1456 additions and 26 deletions

View File

@@ -136,7 +136,7 @@ function isOpenAPIFile(content: any, filePath?: string): boolean {
}
// Check file path patterns for common OpenAPI file structures
// Only accept files in OpenAPI-related directories
// Only accept files in OpenAPI-related directories, but validate content first
if (filePath) {
const path = filePath.toLowerCase();
@@ -155,8 +155,34 @@ function isOpenAPIFile(content: any, filePath?: string): boolean {
path.includes("/webhooks/") ||
path.includes("/paths/")
) {
// Fast-path: if filepath matches, still validate content to prevent
// generic JSON from being misclassified as OpenAPI
const keys = Object.keys(content);
const hasOnlyGenericProperties = keys.every(
(key) =>
!key.startsWith("x-") && // Not a custom extension
![
"openapi",
"swagger",
"info",
"paths",
"components",
"definitions",
"parameters",
"responses",
"securityDefinitions",
"tags",
"servers",
"webhooks",
].includes(key)
);
// Only accept if content doesn't have only generic properties
if (!hasOnlyGenericProperties) {
return true;
}
// If it has only generic properties, fall through to continue validation
}
}
// Check for component-like structures (only if we have strong indicators)

View File

@@ -53,7 +53,7 @@ if (!utils) {
const hasSingle = str.includes("'");
const hasDouble = str.includes('"');
if (hasSingle && !hasDouble) return '"';
return "'";
return '"';
},
};
}

View File

@@ -4,10 +4,17 @@ import getMaxContinuousCount from "../utils/get-max-continuous-count.js";
import inferParser from "../utils/infer-parser.js";
import { getFencedCodeBlockValue } from "./utils.js";
// Skip embedding extremely large code blocks to avoid expensive formatting passes
const MAX_EMBED_CODE_SIZE = 32 * 1024; // 32KB
function embed(path, options) {
const { node } = path;
if (node.type === "code" && node.lang !== null) {
// Fast path: large code blocks are printed as-is without embedding
if (typeof node.value === "string" && node.value.length > MAX_EMBED_CODE_SIZE) {
return null;
}
const parser = inferParser(options, { language: node.lang });
if (parser) {
return async (textToDoc) => {

View File

@@ -24,21 +24,49 @@ import wikiLink from "./unified-plugins/wiki-link.js";
* interface Sentence { children: Array<Word | Whitespace> }
* interface InlineCode { children: Array<Sentence> }
*/
function createParse({ isMDX }) {
return (text) => {
// Memoized processors keyed by feature signature to avoid rebuilding pipelines
const processorCache = new Map();
function detectFeatures(text) {
// Cheap checks to decide whether to enable heavier plugins
const hasMath = /(\$\$|\\\[|\\\(|\$)/u.test(text);
const hasLiquid = /(\{\{|\{%)/u.test(text);
const hasWiki = /\[\[/u.test(text);
return { hasMath, hasLiquid, hasWiki };
}
function getProcessor({ isMDX, features }) {
const key = `${isMDX ? 1 : 0}:${features.hasMath ? 1 : 0}${features.hasLiquid ? 1 : 0}${features.hasWiki ? 1 : 0}`;
const cached = processorCache.get(key);
if (cached) {
return cached;
}
const processor = unified()
.use(remarkParse, {
commonmark: true,
...(isMDX && { blocks: [BLOCKS_REGEX] }),
})
// inexpensive plugins first
.use(footnotes)
.use(frontMatter)
.use(remarkMath)
.use(isMDX ? esSyntax : noop)
.use(liquid)
// conditional heavy plugins
.use(features.hasMath ? remarkMath : noop)
.use(features.hasLiquid ? liquid : noop)
// html -> jsx only matters in MDX
.use(isMDX ? htmlToJsx : noop)
.use(wikiLink);
return processor.run(processor.parse(text));
.use(features.hasWiki ? wikiLink : noop);
processorCache.set(key, processor);
return processor;
}
function createParse({ isMDX }) {
return (text) => {
const features = detectFeatures(text);
const processor = getProcessor({ isMDX, features });
return processor.runSync(processor.parse(text));
};
}

View File

@@ -186,9 +186,17 @@ function getOrderedListItemInfo(orderListItem, options) {
orderListItem.position.end.offset,
);
const { numberText, leadingSpaces } = text.match(
const m = text.match(
/^\s*(?<numberText>\d+)(\.|\))(?<leadingSpaces>\s*)/u,
).groups;
);
if (!m) {
throw new Error(
`Failed to parse ordered list item: expected pattern matching /^\\s*(?<numberText>\\d+)(\\.|\\))(?<leadingSpaces>\\s*)/u, but got: ${JSON.stringify(text)}`,
);
}
const { numberText, leadingSpaces } = m.groups;
return { number: Number(numberText), leadingSpaces };
}

File diff suppressed because it is too large Load Diff

117
test/extensions-api.test.ts Normal file
View File

@@ -0,0 +1,117 @@
import { describe, expect, it } from 'bun:test';
import {
createContextExtensions,
isValidExtensionKey,
createPositionHelpers,
} from '../src/extensions/index.js';
describe('Extension API Tests', () => {
describe('isValidExtensionKey', () => {
it('should return true for valid extension keys starting with x-', () => {
expect(isValidExtensionKey('x-test')).toBeTrue();
expect(isValidExtensionKey('x-custom-field')).toBeTrue();
expect(isValidExtensionKey('x-speakeasy-sdk-name')).toBeTrue();
});
it('should return false for keys not starting with x-', () => {
expect(isValidExtensionKey('test')).toBeFalse();
expect(isValidExtensionKey('custom-field')).toBeFalse();
expect(isValidExtensionKey('description')).toBeFalse();
});
it('should handle edge cases', () => {
expect(isValidExtensionKey('')).toBeFalse();
expect(isValidExtensionKey('x')).toBeFalse();
expect(isValidExtensionKey('x-')).toBeTrue();
});
});
describe('createContextExtensions', () => {
it('should create context extensions for info context', () => {
const extensions = createContextExtensions('info', (before, after) => ({
'x-custom-before-title': before('title'),
'x-custom-after-title': after('title'),
}));
expect(extensions).toBeDefined();
expect(extensions.info).toBeDefined();
expect(typeof extensions.info).toBe('function');
});
it('should create context extensions for operation context', () => {
const extensions = createContextExtensions('operation', (before, after) => ({
'x-custom-before-summary': before('summary'),
'x-custom-after-summary': after('summary'),
}));
expect(extensions).toBeDefined();
expect(extensions.operation).toBeDefined();
});
it('should create context extensions for schema context', () => {
const extensions = createContextExtensions('schema', (before, after) => ({
'x-custom-before-type': before('type'),
'x-custom-after-type': after('type'),
}));
expect(extensions).toBeDefined();
expect(extensions.schema).toBeDefined();
});
});
describe('createPositionHelpers', () => {
it('should create position helpers for info context', () => {
const helpers = createPositionHelpers('info');
expect(helpers).toBeDefined();
expect(helpers.before).toBeDefined();
expect(helpers.after).toBeDefined();
expect(helpers.getAvailableKeys).toBeDefined();
expect(helpers.isValidKey).toBeDefined();
expect(typeof helpers.before).toBe('function');
expect(typeof helpers.after).toBe('function');
expect(typeof helpers.getAvailableKeys).toBe('function');
expect(typeof helpers.isValidKey).toBe('function');
});
it('should return available keys for info context', () => {
const helpers = createPositionHelpers('info');
const keys = helpers.getAvailableKeys();
expect(Array.isArray(keys)).toBeTrue();
expect(keys.length).toBeGreaterThan(0);
expect(keys).toContain('title');
expect(keys).toContain('version');
});
it('should validate keys for info context', () => {
const helpers = createPositionHelpers('info');
expect(helpers.isValidKey('title')).toBeTrue();
expect(helpers.isValidKey('version')).toBeTrue();
expect(helpers.isValidKey('invalid-key')).toBeFalse();
});
it('should create position helpers for operation context', () => {
const helpers = createPositionHelpers('operation');
expect(helpers).toBeDefined();
const keys = helpers.getAvailableKeys();
expect(keys.length).toBeGreaterThan(0);
expect(keys).toContain('summary');
expect(keys).toContain('operationId');
});
it('should create position helpers for schema context', () => {
const helpers = createPositionHelpers('schema');
expect(helpers).toBeDefined();
const keys = helpers.getAvailableKeys();
expect(keys.length).toBeGreaterThan(0);
expect(keys).toContain('type');
expect(keys).toContain('properties');
});
});
});

View File

@@ -159,17 +159,40 @@ city: New York`;
expect(parsedData?.isOpenAPI).toBeFalse();
});
it('should accept files in OpenAPI directories even with simple content', () => {
it('should reject generic content in OpenAPI component directories', () => {
const parser = parsers?.['openapi-parser'];
expect(parser).toBeDefined();
const simpleYaml = `name: John
// Generic content that doesn't match OpenAPI structure
const genericYaml = `name: John
age: 30
city: New York`;
// @ts-expect-error We are mocking things here
const result = parser?.parse(simpleYaml, { filepath: 'components/schemas/User.yaml' });
const result = parser?.parse(genericYaml, { filepath: 'components/schemas/User.yaml' });
expect(result).toBeDefined();
// Should be rejected even though it's in a component directory
expect(result?.isOpenAPI).toBeFalse();
// Format may be undefined when isOpenAPI is false
// The important thing is that it's rejected
});
it('should accept valid OpenAPI content in component directories', () => {
const parser = parsers?.['openapi-parser'];
expect(parser).toBeDefined();
// Valid schema content in component directory
const schemaYaml = `type: object
properties:
name:
type: string
age:
type: integer`;
// @ts-expect-error We are mocking things here
const result = parser?.parse(schemaYaml, { filepath: 'components/schemas/User.yaml' });
expect(result).toBeDefined();
// Should be accepted because content is valid OpenAPI schema
expect(result?.isOpenAPI).toBeTrue();
expect(result?.format).toBe('yaml');
});

View File

@@ -44,14 +44,14 @@ describe("Markdown Formatting in Descriptions", () => {
throw new Error("Result is undefined");
}
const resultString = result.toString();
const resultString = String(result);
// Check that multiple spaces are normalized in the original content
// Note: YAML may format this differently, but the content should be processed
// The description field should exist and be formatted
expect(resultString).toContain("description:");
// Check that multiple blank lines are normalized
// Check that multiple blank lines are normalized (no quad-blank-lines)
expect(resultString).not.toMatch(/\n{4,}/);
});
@@ -79,7 +79,7 @@ describe("Markdown Formatting in Descriptions", () => {
throw new Error("Result is undefined");
}
const resultString = result.toString();
const resultString = String(result);
// Code blocks (4+ spaces) should be preserved
expect(resultString).toContain(" const x = 1;");
@@ -127,7 +127,7 @@ describe("Markdown Formatting in Descriptions", () => {
throw new Error("Result is undefined");
}
const resultString = result.toString();
const resultString = String(result);
// Both parameter and response descriptions should be formatted
expect(resultString).toContain("description:");
@@ -167,7 +167,7 @@ describe("Markdown Formatting in Descriptions", () => {
throw new Error("Result is undefined");
}
const resultString = result.toString();
const resultString = String(result);
// Summary fields should be processed
expect(resultString).toContain("summary:");