mirror of
https://github.com/LukeHagar/prettier-plugin-openapi.git
synced 2025-12-10 04:21:15 +00:00
Merge pull request #2 from LukeHagar/formatting-markdown
Formatting the GFM
This commit is contained in:
30
src/index.ts
30
src/index.ts
@@ -136,7 +136,7 @@ function isOpenAPIFile(content: any, filePath?: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check file path patterns for common OpenAPI file structures
|
// 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) {
|
if (filePath) {
|
||||||
const path = filePath.toLowerCase();
|
const path = filePath.toLowerCase();
|
||||||
|
|
||||||
@@ -155,7 +155,33 @@ function isOpenAPIFile(content: any, filePath?: string): boolean {
|
|||||||
path.includes("/webhooks/") ||
|
path.includes("/webhooks/") ||
|
||||||
path.includes("/paths/")
|
path.includes("/paths/")
|
||||||
) {
|
) {
|
||||||
return true;
|
// 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ if (!utils) {
|
|||||||
const hasSingle = str.includes("'");
|
const hasSingle = str.includes("'");
|
||||||
const hasDouble = str.includes('"');
|
const hasDouble = str.includes('"');
|
||||||
if (hasSingle && !hasDouble) return '"';
|
if (hasSingle && !hasDouble) return '"';
|
||||||
return "'";
|
return '"';
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,17 @@ import getMaxContinuousCount from "../utils/get-max-continuous-count.js";
|
|||||||
import inferParser from "../utils/infer-parser.js";
|
import inferParser from "../utils/infer-parser.js";
|
||||||
import { getFencedCodeBlockValue } from "./utils.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) {
|
function embed(path, options) {
|
||||||
const { node } = path;
|
const { node } = path;
|
||||||
|
|
||||||
if (node.type === "code" && node.lang !== null) {
|
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 });
|
const parser = inferParser(options, { language: node.lang });
|
||||||
if (parser) {
|
if (parser) {
|
||||||
return async (textToDoc) => {
|
return async (textToDoc) => {
|
||||||
|
|||||||
@@ -24,21 +24,49 @@ import wikiLink from "./unified-plugins/wiki-link.js";
|
|||||||
* interface Sentence { children: Array<Word | Whitespace> }
|
* interface Sentence { children: Array<Word | Whitespace> }
|
||||||
* interface InlineCode { children: Array<Sentence> }
|
* interface InlineCode { children: Array<Sentence> }
|
||||||
*/
|
*/
|
||||||
|
// 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(isMDX ? esSyntax : noop)
|
||||||
|
// conditional heavy plugins
|
||||||
|
.use(features.hasMath ? remarkMath : noop)
|
||||||
|
.use(features.hasLiquid ? liquid : noop)
|
||||||
|
// html -> jsx only matters in MDX
|
||||||
|
.use(isMDX ? htmlToJsx : noop)
|
||||||
|
.use(features.hasWiki ? wikiLink : noop);
|
||||||
|
|
||||||
|
processorCache.set(key, processor);
|
||||||
|
return processor;
|
||||||
|
}
|
||||||
|
|
||||||
function createParse({ isMDX }) {
|
function createParse({ isMDX }) {
|
||||||
return (text) => {
|
return (text) => {
|
||||||
const processor = unified()
|
const features = detectFeatures(text);
|
||||||
.use(remarkParse, {
|
const processor = getProcessor({ isMDX, features });
|
||||||
commonmark: true,
|
return processor.runSync(processor.parse(text));
|
||||||
...(isMDX && { blocks: [BLOCKS_REGEX] }),
|
|
||||||
})
|
|
||||||
.use(footnotes)
|
|
||||||
.use(frontMatter)
|
|
||||||
.use(remarkMath)
|
|
||||||
.use(isMDX ? esSyntax : noop)
|
|
||||||
.use(liquid)
|
|
||||||
.use(isMDX ? htmlToJsx : noop)
|
|
||||||
.use(wikiLink);
|
|
||||||
return processor.run(processor.parse(text));
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -186,9 +186,17 @@ function getOrderedListItemInfo(orderListItem, options) {
|
|||||||
orderListItem.position.end.offset,
|
orderListItem.position.end.offset,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { numberText, leadingSpaces } = text.match(
|
const m = text.match(
|
||||||
/^\s*(?<numberText>\d+)(\.|\))(?<leadingSpaces>\s*)/u,
|
/^\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 };
|
return { number: Number(numberText), leadingSpaces };
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
117
test/extensions-api.test.ts
Normal file
117
test/extensions-api.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -159,17 +159,40 @@ city: New York`;
|
|||||||
expect(parsedData?.isOpenAPI).toBeFalse();
|
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'];
|
const parser = parsers?.['openapi-parser'];
|
||||||
expect(parser).toBeDefined();
|
expect(parser).toBeDefined();
|
||||||
|
|
||||||
const simpleYaml = `name: John
|
// Generic content that doesn't match OpenAPI structure
|
||||||
|
const genericYaml = `name: John
|
||||||
age: 30
|
age: 30
|
||||||
city: New York`;
|
city: New York`;
|
||||||
|
|
||||||
// @ts-expect-error We are mocking things here
|
// @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();
|
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?.isOpenAPI).toBeTrue();
|
||||||
expect(result?.format).toBe('yaml');
|
expect(result?.format).toBe('yaml');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ describe("Markdown Formatting in Descriptions", () => {
|
|||||||
throw new Error("Result is undefined");
|
throw new Error("Result is undefined");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultString = result.toString();
|
const resultString = String(result);
|
||||||
|
|
||||||
// Check that multiple spaces are normalized in the original content
|
// Check that multiple spaces are normalized in the original content
|
||||||
// Note: YAML may format this differently, but the content should be processed
|
// Note: YAML may format this differently, but the content should be processed
|
||||||
// The description field should exist and be formatted
|
// The description field should exist and be formatted
|
||||||
expect(resultString).toContain("description:");
|
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,}/);
|
expect(resultString).not.toMatch(/\n{4,}/);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ describe("Markdown Formatting in Descriptions", () => {
|
|||||||
throw new Error("Result is undefined");
|
throw new Error("Result is undefined");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultString = result.toString();
|
const resultString = String(result);
|
||||||
|
|
||||||
// Code blocks (4+ spaces) should be preserved
|
// Code blocks (4+ spaces) should be preserved
|
||||||
expect(resultString).toContain(" const x = 1;");
|
expect(resultString).toContain(" const x = 1;");
|
||||||
@@ -127,7 +127,7 @@ describe("Markdown Formatting in Descriptions", () => {
|
|||||||
throw new Error("Result is undefined");
|
throw new Error("Result is undefined");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultString = result.toString();
|
const resultString = String(result);
|
||||||
|
|
||||||
// Both parameter and response descriptions should be formatted
|
// Both parameter and response descriptions should be formatted
|
||||||
expect(resultString).toContain("description:");
|
expect(resultString).toContain("description:");
|
||||||
@@ -167,7 +167,7 @@ describe("Markdown Formatting in Descriptions", () => {
|
|||||||
throw new Error("Result is undefined");
|
throw new Error("Result is undefined");
|
||||||
}
|
}
|
||||||
|
|
||||||
const resultString = result.toString();
|
const resultString = String(result);
|
||||||
|
|
||||||
// Summary fields should be processed
|
// Summary fields should be processed
|
||||||
expect(resultString).toContain("summary:");
|
expect(resultString).toContain("summary:");
|
||||||
|
|||||||
Reference in New Issue
Block a user