Swizzle Prettier Markdown

This commit is contained in:
Luke Hagar
2025-11-10 16:45:57 +00:00
parent 0eeee55544
commit 779e410cb8
36 changed files with 4389 additions and 94 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "prettier-plugin-openapi",
"version": "1.0.11",
"version": "1.0.12",
"description": "A Prettier plugin for formatting OpenAPI/Swagger JSON and YAML files",
"author": {
"name": "Luke Hagar",

View File

@@ -1,6 +1,5 @@
import * as yaml from "js-yaml";
import type { AstPath, Doc, Parser, ParserOptions, Printer, SupportLanguage } from "prettier";
import prettier from "prettier";
import { getVendorExtensions } from "./extensions/vendor-loader.js";
export type PrintFn = (path: AstPath) => Doc;
@@ -321,8 +320,10 @@ export const printers: Record<string, Printer> = {
};
/**
* Formats markdown strings using Prettier's markdown formatter
* Since Prettier plugins must be synchronous, we access Prettier's internal formatting APIs
* Formats markdown strings using Prettier's markdown parser and printer
*
* This uses Prettier's actual markdown formatting implementation, ensuring
* that markdown in OpenAPI descriptions is formatted exactly as Prettier would format it.
*/
function formatMarkdownSync(markdown: string, options?: OpenAPIPluginOptions): string {
if (!markdown || typeof markdown !== "string") {
@@ -341,100 +342,21 @@ function formatMarkdownSync(markdown: string, options?: OpenAPIPluginOptions): s
}
try {
const prettierInstance = prettier as any;
// Try multiple approaches to access Prettier's markdown formatting
// Approach 1: Try formatDocument (internal API)
if (prettierInstance.formatDocument) {
try {
const formatted = prettierInstance.formatDocument(trimmed, {
parser: "markdown",
// Use Prettier's markdown formatter
// Dynamic require to avoid issues during build
const formatModule = require("./prettier-markdown/format-markdown.js");
const formatted = formatModule.formatMarkdown(trimmed, {
printWidth: options?.printWidth || 80,
tabWidth: options?.tabWidth || 2,
proseWrap: "preserve",
singleQuote: false,
});
return formatted.trimEnd();
} catch {
// Fall through to next approach
}
}
// Approach 2: Try to access Prettier's markdown formatting synchronously
// Since Prettier.format is async, we need to find a sync way to format markdown
// Prettier's internal APIs might not expose sync formatting, so we'll need to
// either use a sync wrapper or do intelligent normalization
// Try using Prettier's format through require (if available in sync context)
try {
const prettierModule = require("prettier");
// Check if there's a synchronous formatting method
// Prettier 3.x might expose different APIs
if (prettierModule.__internal?.markdown?.formatSync) {
const formatted = prettierModule.__internal.markdown.formatSync(trimmed, {
printWidth: options?.printWidth || 80,
tabWidth: options?.tabWidth || 2,
proseWrap: "preserve",
});
return formatted.trimEnd();
}
// Try accessing Prettier's internal formatting through utils
if (prettierModule.__internal?.utils) {
const utils = prettierModule.__internal.utils;
// Check if there's a sync formatter in utils
// This is a best-effort attempt to use Prettier's formatting
}
} catch {
// Prettier's internal APIs may not be accessible or may not be sync
// Fall through to manual formatting
}
// Approach 3: Manual markdown formatting
// Since Prettier.format is async and plugins must be sync,
// we do intelligent markdown normalization that matches Prettier's style
let formatted = trimmed;
// Normalize line breaks (preserve intentional double line breaks)
formatted = formatted.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Normalize multiple blank lines to maximum of 2 (preserve paragraph breaks)
formatted = formatted.replace(/\n{3,}/g, "\n\n");
// Process each line
const lines = formatted.split("\n");
const processedLines = lines.map((line) => {
// Preserve code block indentation (lines starting with 4+ spaces or tabs)
if (/^[\t ]{4,}/.test(line)) {
// Code block - preserve but normalize trailing whitespace
return line.trimEnd();
}
// For regular lines, normalize multiple spaces to single spaces
// But preserve intentional formatting (like tables, lists with specific spacing)
// First, trim trailing whitespace
let processed = line.trimEnd();
// Normalize multiple spaces to single space, but preserve:
// - Multiple spaces at start (indentation for nested lists)
// - Patterns that look intentional (like markdown tables)
if (!/^[\s]*\|/.test(processed) && !/^[\s]*[-*+]\s{2,}/.test(processed)) {
// Not a table or list, normalize spaces
processed = processed.replace(/[ \t]+/g, " ");
}
return processed;
});
formatted = processedLines.join("\n");
// Remove trailing newline if we added one (we want YAML to control formatting)
formatted = formatted.trimEnd();
return formatted;
} catch (error) {
// If markdown formatting fails, return original string
return markdown;
// If Prettier's formatter fails, fall back to basic normalization
// This ensures we always return valid markdown
return trimmed;
}
}

View File

@@ -0,0 +1,54 @@
# Prettier Markdown Integration
This directory contains Prettier's markdown language implementation, adapted for use within this plugin.
## Structure
### Core Files (from Prettier)
These files are copied from Prettier's `src/language-markdown` directory:
- `parser-markdown.js` - Markdown parser
- `printer-markdown.js` - Markdown printer
- `clean.js` - AST cleaning utilities
- `utils.js` - Utility functions
- `constants.evaluate.js` - Constants
- `print/` - Printing utilities
- `unified-plugins/` - Unified/Remark plugins
- Other supporting files
### Adapter Files (created for this plugin)
These files adapt Prettier's internal dependencies:
- `adapter-document-builders.js` - Adapts Prettier's document builders
- `adapter-document-utils.js` - Adapts Prettier's document utilities
- `adapter-document-constants.js` - Adapts Prettier's document constants
- `adapter-prettier-utils.js` - Adapts Prettier's utility functions
- `adapter-pragma.js` - Adapts pragma handling (simplified)
### Integration Files
- `format-markdown.ts` - Type-safe wrapper for formatting markdown
- `options.js` - Markdown formatting options (adapted from Prettier)
## Updating from Prettier
When Prettier updates its markdown implementation:
1. **Copy updated files** from `prettier/src/language-markdown/` to this directory
2. **Update adapter files** if Prettier's internal structure changed:
- Check if document builders path changed → update `adapter-document-builders.js`
- Check if document utils path changed → update `adapter-document-utils.js`
- Check if utility functions changed → update `adapter-prettier-utils.js`
3. **Update imports** in copied files to use adapter files:
- `../document/builders.js``./adapter-document-builders.js`
- `../document/utils.js``./adapter-document-utils.js`
- `../document/constants.js``./adapter-document-constants.js`
- `../utils/*``./adapter-prettier-utils.js`
- `../common/common-options.evaluate.js` → update `options.js`
- `../main/front-matter/index.js` → update `adapter-pragma.js` or `clean.js`
- `../utils/pragma/pragma.evaluate.js` → update `adapter-pragma.js`
4. **Test** that markdown formatting still works correctly
## Dependencies
The adapter files attempt to access Prettier's internal APIs at runtime. If Prettier's internal structure changes significantly, you may need to update the adapter files to match.
The interfaces in the adapter files are designed to be similar to Prettier's actual structure, making updates easier.

View File

@@ -0,0 +1,55 @@
/**
* Adapter for Prettier's document builders
*
* This file attempts to import Prettier's document builders from internal APIs.
* Update this file when Prettier's internal structure changes.
*/
let builders: any = null;
try {
const prettier = require("prettier");
// Try multiple paths to access document builders
if (prettier.__internal?.document?.builders) {
builders = prettier.__internal.document.builders;
} else {
// Try to require directly (may work in plugin context)
try {
builders = require("prettier/internal/document/builders");
} catch {
// Fallback: try alternative paths
try {
const doc = require("prettier/doc");
if (doc) {
builders = doc;
}
} catch {
// Not accessible
}
}
}
} catch {
// Builders not accessible
}
// Export what we found, or throw if not available
if (!builders) {
throw new Error(
"Prettier document builders not accessible. " +
"Markdown formatting requires Prettier's internal document builders."
);
}
export const {
align,
fill,
group,
hardline,
indent,
line,
literalline,
markAsRoot,
softline,
} = builders;

View File

@@ -0,0 +1,33 @@
/**
* Adapter for Prettier's document constants
*
* This file attempts to import Prettier's document constants from internal APIs.
* Update this file when Prettier's internal structure changes.
*/
let constants: any = null;
try {
const prettier = require("prettier");
if (prettier.__internal?.document?.constants) {
constants = prettier.__internal.document.constants;
} else {
try {
constants = require("prettier/internal/document/constants");
} catch {
// Constants may not be accessible, provide a fallback
constants = {
DOC_TYPE_STRING: "doc-type-string",
};
}
}
} catch {
// Provide fallback
constants = {
DOC_TYPE_STRING: "doc-type-string",
};
}
export const { DOC_TYPE_STRING } = constants;

View File

@@ -0,0 +1,39 @@
/**
* Adapter for Prettier's document utils
*
* This file attempts to import Prettier's document utilities from internal APIs.
* Update this file when Prettier's internal structure changes.
*/
let utils: any = null;
try {
const prettier = require("prettier");
if (prettier.__internal?.document?.utils) {
utils = prettier.__internal.document.utils;
} else {
try {
utils = require("prettier/internal/document/utils");
} catch {
try {
const docUtils = require("prettier/doc");
utils = docUtils;
} catch {
// Not accessible
}
}
}
} catch {
// Utils not accessible
}
if (!utils) {
throw new Error(
"Prettier document utils not accessible. " +
"Markdown formatting requires Prettier's internal document utilities."
);
}
export const { getDocType, replaceEndOfLine } = utils;

View File

@@ -0,0 +1,48 @@
/**
* Adapter for Prettier's pragma utilities
*
* Simplified version that doesn't depend on Prettier's internal pragma system.
* Update this file when Prettier's pragma behavior changes.
*/
// Simplified pragma regexes based on Prettier's implementation
const MARKDOWN_HAS_PRAGMA_REGEXP = /^<!--\s*@(prettier|format)\s*-->$/m;
const MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP = /^<!--\s*prettier-ignore(?:-(start|end))?\s*-->$/m;
const FORMAT_PRAGMA_TO_INSERT = "format";
/**
* Simple front matter parser (minimal implementation)
*/
function parseFrontMatter(text: string) {
const yamlMatch = text.match(/^---\s*\n([\s\S]*?)\n---\s*\n/);
if (yamlMatch) {
return {
content: text.slice(yamlMatch[0].length),
frontMatter: {
raw: yamlMatch[0],
end: { index: yamlMatch[0].length },
},
};
}
return { content: text, frontMatter: null };
}
const hasPragma = (text: string) =>
parseFrontMatter(text).content.trimStart().match(MARKDOWN_HAS_PRAGMA_REGEXP)
?.index === 0;
const hasIgnorePragma = (text: string) =>
parseFrontMatter(text)
.content.trimStart()
.match(MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP)?.index === 0;
const insertPragma = (text: string) => {
const { frontMatter } = parseFrontMatter(text);
const pragma = `<!-- @${FORMAT_PRAGMA_TO_INSERT} -->`;
return frontMatter
? `${frontMatter.raw}\n\n${pragma}\n\n${text.slice(frontMatter.end.index)}`
: `${pragma}\n\n${text}`;
};
export { hasIgnorePragma, hasPragma, insertPragma };

View File

@@ -0,0 +1,130 @@
/**
* Adapter for Prettier's internal document builders and utilities
*
* This file attempts to access Prettier's internal APIs that are needed
* by the markdown parser and printer. These adapters provide a type-safe
* way to access Prettier internals when available.
*
* Note: This file can be updated when Prettier's internal structure changes.
* The interfaces should remain similar to allow easy updates.
*/
/**
* Attempts to import Prettier's document builders
* @returns {Promise<typeof import('prettier/internal/document/builders')> | null>}
*/
export function getDocumentBuilders() {
try {
// Try to require Prettier's document builders
// Prettier 3.x structure
const prettier = require("prettier");
// Path 1: Check if accessible via __internal
if (prettier.__internal?.document?.builders) {
return prettier.__internal.document.builders;
}
// Path 2: Try to require directly (may work in some contexts)
try {
return require("prettier/internal/document/builders");
} catch {
// Not accessible
}
return null;
} catch {
return null;
}
}
/**
* Attempts to import Prettier's document utils
*/
export function getDocumentUtils() {
try {
const prettier = require("prettier");
if (prettier.__internal?.document?.utils) {
return prettier.__internal.document.utils;
}
try {
return require("prettier/internal/document/utils");
} catch {
return null;
}
} catch {
return null;
}
}
/**
* Attempts to import Prettier's document constants
*/
export function getDocumentConstants() {
try {
const prettier = require("prettier");
if (prettier.__internal?.document?.constants) {
return prettier.__internal.document.constants;
}
try {
return require("prettier/internal/document/constants");
} catch {
return null;
}
} catch {
return null;
}
}
/**
* Attempts to import Prettier's utility functions
*/
export function getPrettierUtils() {
try {
const prettier = require("prettier");
if (prettier.__internal?.utils) {
return prettier.__internal.utils;
}
return null;
} catch {
return null;
}
}
/**
* Attempts to get Prettier's doc-to-string printer
* This is needed to convert Doc objects to strings
*/
export function getDocPrinter() {
try {
const prettier = require("prettier");
// Try multiple paths
if (prettier.__internal?.doc?.printDocToString) {
return prettier.__internal.doc.printDocToString;
}
if (prettier.__internal?.docPrinter?.formatDoc) {
return prettier.__internal.docPrinter.formatDoc;
}
try {
const docUtils = require("prettier/internal/doc");
if (docUtils?.printDocToString) {
return docUtils.printDocToString;
}
} catch {
// Not accessible
}
return null;
} catch {
return null;
}
}

View File

@@ -0,0 +1,72 @@
/**
* Adapter for Prettier's utility functions
*
* This file attempts to import Prettier's utility functions from internal APIs.
* Update this file when Prettier's internal structure changes.
*/
let utils: any = null;
try {
const prettier = require("prettier");
if (prettier.__internal?.utils) {
utils = prettier.__internal.utils;
} else {
try {
// Try alternative paths
utils = require("prettier/internal/utils");
} catch {
// Utils may not be accessible
}
}
} catch {
// Utils not accessible
}
// Provide fallback implementations if utils aren't accessible
if (!utils) {
// Minimal fallback implementations
utils = {
getMaxContinuousCount: (str: string, char: string) => {
let max = 0;
let current = 0;
for (const c of str) {
if (c === char) {
current++;
max = Math.max(max, current);
} else {
current = 0;
}
}
return max;
},
getMinNotPresentContinuousCount: (str: string, char: string) => {
let count = 1;
while (str.includes(char.repeat(count))) {
count++;
}
return count;
},
getPreferredQuote: (str: string, singleQuote: boolean) => {
if (singleQuote) return "'";
const hasSingle = str.includes("'");
const hasDouble = str.includes('"');
if (hasSingle && !hasDouble) return '"';
return "'";
},
};
}
export const getMaxContinuousCount = utils.getMaxContinuousCount;
export const getMinNotPresentContinuousCount = utils.getMinNotPresentContinuousCount;
export const getPreferredQuote = utils.getPreferredQuote;
// UnexpectedNodeError may be in utils or separate
export class UnexpectedNodeError extends Error {
constructor(node: any, language: string) {
super(`Unexpected node type: ${node.type} in ${language}`);
this.name = "UnexpectedNodeError";
}
}

View File

@@ -0,0 +1,91 @@
import collapseWhiteSpace from "collapse-white-space";
import { hasPragma } from "./adapter-pragma.js";
// Simplified front matter check
function isFrontMatter(node: any): boolean {
return node?.type === "yaml" || node?.type === "toml";
}
const ignoredProperties = new Set([
"position",
"raw", // front-matter
]);
function clean(original, cloned, parent) {
// for codeblock
if (
original.type === "code" ||
original.type === "yaml" ||
original.type === "import" ||
original.type === "export" ||
original.type === "jsx"
) {
delete cloned.value;
}
if (original.type === "list") {
delete cloned.isAligned;
}
if (original.type === "list" || original.type === "listItem") {
delete cloned.spread;
}
// texts can be splitted or merged
if (original.type === "text") {
return null;
}
if (original.type === "inlineCode") {
cloned.value = original.value.replaceAll("\n", " ");
}
if (original.type === "wikiLink") {
cloned.value = original.value.trim().replaceAll(/[\t\n]+/gu, " ");
}
if (
original.type === "definition" ||
original.type === "linkReference" ||
original.type === "imageReference"
) {
cloned.label = collapseWhiteSpace(original.label);
}
if (
(original.type === "link" || original.type === "image") &&
original.url &&
original.url.includes("(")
) {
for (const character of "<>") {
cloned.url = original.url.replaceAll(
character,
encodeURIComponent(character),
);
}
}
if (
(original.type === "definition" ||
original.type === "link" ||
original.type === "image") &&
original.title
) {
cloned.title = original.title.replaceAll(/\\(?=["')])/gu, "");
}
// for insert pragma
if (
parent?.type === "root" &&
parent.children.length > 0 &&
(parent.children[0] === original ||
(isFrontMatter(parent.children[0]) && parent.children[1] === original)) &&
original.type === "html" &&
hasPragma(original.value)
) {
return null;
}
}
clean.ignoredProperties = ignoredProperties;
export default clean;

View File

@@ -0,0 +1,86 @@
import { all as getCjkCharset } from "cjk-regex";
import { Charset } from "regexp-util";
import unicodeRegex from "unicode-regex";
const cjkCharset = new Charset(
getCjkCharset(),
unicodeRegex({
Script_Extensions: ["Han", "Katakana", "Hiragana", "Hangul", "Bopomofo"],
General_Category: [
"Other_Letter",
"Letter_Number",
"Other_Symbol",
"Modifier_Letter",
"Modifier_Symbol",
"Nonspacing_Mark",
],
}),
);
const variationSelectorsCharset = unicodeRegex({
Block: ["Variation_Selectors", "Variation_Selectors_Supplement"],
});
const CJK_REGEXP = new RegExp(
`(?:${cjkCharset.toString("u")})(?:${variationSelectorsCharset.toString("u")})?`,
"u",
);
const asciiPunctuationCharacters = [
"!",
'"',
"#",
"$",
"%",
"&",
"'",
"(",
")",
"*",
"+",
",",
"-",
".",
"/",
":",
";",
"<",
"=",
">",
"?",
"@",
"[",
"\\",
"]",
"^",
"_",
"`",
"{",
"|",
"}",
"~",
];
// https://spec.commonmark.org/0.25/#punctuation-character
// https://unicode.org/Public/5.1.0/ucd/UCD.html#General_Category_Values
const unicodePunctuationClasses = [
/* Pc */ "Connector_Punctuation",
/* Pd */ "Dash_Punctuation",
/* Pe */ "Close_Punctuation",
/* Pf */ "Final_Punctuation",
/* Pi */ "Initial_Punctuation",
/* Po */ "Other_Punctuation",
/* Ps */ "Open_Punctuation",
];
const PUNCTUATION_REGEXP = new RegExp(
`(?:${[
new Charset(...asciiPunctuationCharacters).toRegExp("u").source,
...unicodePunctuationClasses.map(
(charset) => String.raw`\p{General_Category=${charset}}`,
"\u{ff5e}", // Used as a substitute for U+301C in Windows
),
].join("|")})`,
"u",
);
export { CJK_REGEXP, PUNCTUATION_REGEXP };

View File

@@ -0,0 +1,87 @@
import { hardline, markAsRoot } from "../document/builders.js";
import { replaceEndOfLine } from "../document/utils.js";
import getMaxContinuousCount from "../utils/get-max-continuous-count.js";
import inferParser from "../utils/infer-parser.js";
import { getFencedCodeBlockValue } from "./utils.js";
function embed(path, options) {
const { node } = path;
if (node.type === "code" && node.lang !== null) {
const parser = inferParser(options, { language: node.lang });
if (parser) {
return async (textToDoc) => {
const styleUnit = options.__inJsTemplate ? "~" : "`";
const style = styleUnit.repeat(
Math.max(3, getMaxContinuousCount(node.value, styleUnit) + 1),
);
const newOptions = { parser };
// Override the filepath option.
// This is because whether the trailing comma of type parameters
// should be printed depends on whether it is `*.ts` or `*.tsx`.
// https://github.com/prettier/prettier/issues/15282
if (node.lang === "ts" || node.lang === "typescript") {
newOptions.filepath = "dummy.ts";
} else if (node.lang === "tsx") {
newOptions.filepath = "dummy.tsx";
}
const doc = await textToDoc(
getFencedCodeBlockValue(node, options.originalText),
newOptions,
);
return markAsRoot([
style,
node.lang,
node.meta ? " " + node.meta : "",
hardline,
replaceEndOfLine(doc),
hardline,
style,
]);
};
}
}
switch (node.type) {
// MDX
case "import":
case "export":
return (textToDoc) =>
textToDoc(node.value, {
// TODO: Rename this option since it's not used in HTML
__onHtmlBindingRoot: (ast) => validateImportExport(ast, node.type),
parser: "babel",
});
case "jsx":
return (textToDoc) =>
textToDoc(`<$>${node.value}</$>`, {
parser: "__js_expression",
rootMarker: "mdx",
});
}
return null;
}
function validateImportExport(ast, type) {
const {
program: { body },
} = ast;
// https://github.com/mdx-js/mdx/blob/3430138958c9c0344ecad9d59e0d6b5d72bedae3/packages/remark-mdx/extract-imports-and-exports.js#L16
if (
!body.every(
(node) =>
node.type === "ImportDeclaration" ||
node.type === "ExportDefaultDeclaration" ||
node.type === "ExportNamedDeclaration",
)
) {
throw new Error(`Unexpected '${type}' in MDX.`);
}
}
export default embed;

View File

@@ -0,0 +1,123 @@
/**
* Type-safe wrapper for Prettier's markdown formatting
*
* This module provides a synchronous interface to Prettier's markdown
* parser and printer, adapted to work within a Prettier plugin context.
*/
const { markdown: markdownParser } = require("./parser-markdown.js");
const printer = require("./printer-markdown.js");
const { getDocPrinter } = require("./adapter-prettier-internals.js");
/**
* Formats a markdown string using Prettier's markdown parser and printer
*
* @param {string} markdown - The markdown string to format
* @param {Object} options - Formatting options
* @param {number} [options.printWidth=80] - Maximum line width
* @param {number} [options.tabWidth=2] - Tab width
* @param {string} [options.proseWrap='preserve'] - Prose wrapping mode
* @param {boolean} [options.singleQuote=false] - Use single quotes
* @returns {string} The formatted markdown string, or the original if formatting fails
*/
function formatMarkdown(markdown, options = {}) {
if (!markdown || typeof markdown !== "string") {
return markdown;
}
const trimmed = markdown.trim();
if (trimmed.length === 0) {
return markdown;
}
try {
// Parse markdown to AST
const ast = markdownParser.parse(trimmed, {
originalText: trimmed,
filepath: "temp.md",
printWidth: options.printWidth || 80,
tabWidth: options.tabWidth || 2,
proseWrap: options.proseWrap || "preserve",
singleQuote: options.singleQuote || false,
});
// Create an AstPath-like object for the printer
const astPath = {
getNode: () => ast,
stack: [ast],
node: ast,
callParent: (fn) => fn(astPath),
each: (fn) => {
if (ast.children) {
ast.children.forEach((child, index) => {
const childPath = {
getNode: () => child,
stack: [...astPath.stack, child],
node: child,
index,
previous: index > 0 ? ast.children[index - 1] : null,
next: index < ast.children.length - 1 ? ast.children[index + 1] : null,
parent: ast,
isFirst: index === 0,
isLast: index === ast.children.length - 1,
callParent: (fn) => fn(childPath),
};
fn(childPath);
});
}
},
};
// Create a print function for recursive printing
const createPrintFn = (path) => {
return (printPath) => {
return printer.print(printPath, {
printWidth: options.printWidth || 80,
tabWidth: options.tabWidth || 2,
proseWrap: options.proseWrap || "preserve",
singleQuote: options.singleQuote || false,
originalText: trimmed,
}, createPrintFn);
};
};
// Print the AST to a Doc object
const doc = printer.print(astPath, {
printWidth: options.printWidth || 80,
tabWidth: options.tabWidth || 2,
proseWrap: options.proseWrap || "preserve",
singleQuote: options.singleQuote || false,
originalText: trimmed,
}, createPrintFn);
// Convert Doc to string
if (typeof doc === "string") {
return doc.trimEnd();
}
// Try to convert Doc object to string using Prettier's doc printer
const docPrinter = getDocPrinter();
if (docPrinter && typeof docPrinter === "function") {
try {
const formattedString = docPrinter(doc, {
printWidth: options.printWidth || 80,
tabWidth: options.tabWidth || 2,
useTabs: false,
});
return typeof formattedString === "string" ? formattedString.trimEnd() : markdown;
} catch {
// Doc printing failed
return markdown;
}
}
// If we can't convert Doc to string, return original
return markdown;
} catch (error) {
// Parsing or printing failed, return original
return markdown;
}
}
module.exports = { formatMarkdown };

View File

@@ -0,0 +1,113 @@
/**
* Type-safe wrapper for Prettier's markdown formatting
*
* This module provides a synchronous interface to Prettier's markdown
* parser and printer, adapted to work within a Prettier plugin context.
*/
import type { ParserOptions } from "prettier";
import {
getDocPrinter,
getDocumentBuilders,
getDocumentConstants,
getDocumentUtils,
// @ts-expect-error - No declaration file
} from "./adapter-prettier-internals.js";
// @ts-expect-error - No declaration file
import { markdown as markdownParser } from "./parser-markdown.js";
// @ts-expect-error - No declaration file
import printer from "./printer-markdown.js";
interface MarkdownFormatOptions {
printWidth?: number;
tabWidth?: number;
proseWrap?: "always" | "never" | "preserve";
singleQuote?: boolean;
}
/**
* Formats a markdown string using Prettier's markdown parser and printer
*
* @param markdown - The markdown string to format
* @param options - Formatting options
* @returns The formatted markdown string, or the original if formatting fails
*/
export function formatMarkdown(markdown: string, options: MarkdownFormatOptions = {}): string {
if (!markdown || typeof markdown !== "string") {
return markdown;
}
const trimmed = markdown.trim();
if (trimmed.length === 0) {
return markdown;
}
try {
// Parse markdown to AST
const ast = markdownParser.parse(trimmed, {
originalText: trimmed,
filepath: "temp.md",
} as ParserOptions);
// Create an AstPath-like object for the printer
const astPath = {
getNode: () => ast,
stack: [ast],
callParent: (fn: (path: any) => any) => fn(astPath),
each: (fn: (path: any) => void) => {
if (ast.children) {
ast.children.forEach((child: any, index: number) => {
const childPath = {
getNode: () => child,
stack: [...astPath.stack, child],
index,
previous: index > 0 ? ast.children[index - 1] : null,
next: index < ast.children.length - 1 ? ast.children[index + 1] : null,
parent: ast,
isFirst: index === 0,
isLast: index === ast.children.length - 1,
};
fn(childPath);
});
}
},
};
// Create a print function for recursive printing
const createPrintFn = (path: any): any => {
return (printPath: any) => {
return printer.print(printPath, options as ParserOptions, createPrintFn);
};
};
// Print the AST to a Doc object
const doc = printer.print(astPath, options as ParserOptions, createPrintFn);
// Convert Doc to string
if (typeof doc === "string") {
return doc.trimEnd();
}
// Try to convert Doc object to string using Prettier's doc printer
const docPrinter = getDocPrinter();
if (docPrinter && typeof docPrinter === "function") {
try {
const formattedString = docPrinter(doc, {
printWidth: options.printWidth || 80,
tabWidth: options.tabWidth || 2,
useTabs: false,
});
return typeof formattedString === "string" ? formattedString.trimEnd() : markdown;
} catch {
// Doc printing failed
return markdown;
}
}
// If we can't convert Doc to string, return original
return markdown;
} catch (error) {
// Parsing or printing failed, return original
return markdown;
}
}

View File

@@ -0,0 +1,6 @@
import createGetVisitorKeys from "../utils/create-get-visitor-keys.js";
import visitorKeys from "./visitor-keys.js";
const getVisitorKeys = createGetVisitorKeys(visitorKeys);
export default getVisitorKeys;

View File

@@ -0,0 +1,8 @@
import printer from "./printer-markdown.js";
export const printers = {
mdast: printer,
};
export { default as languages } from "./languages.evaluate.js";
export { default as options } from "./options.js";
export * as parsers from "./parser-markdown.js";

View File

@@ -0,0 +1,20 @@
import * as linguistLanguages from "linguist-languages";
import createLanguage from "../utils/create-language.js";
const languages = [
createLanguage(linguistLanguages.Markdown, (data) => ({
parsers: ["markdown"],
vscodeLanguageIds: ["markdown"],
filenames: [...data.filenames, "README"],
extensions: data.extensions.filter((extension) => extension !== ".mdx"),
})),
createLanguage(linguistLanguages.Markdown, () => ({
name: "MDX",
parsers: ["mdx"],
vscodeLanguageIds: ["mdx"],
filenames: [],
extensions: [".mdx"],
})),
];
export default languages;

View File

@@ -0,0 +1,4 @@
const locStart = (node) => node.position.start.offset;
const locEnd = (node) => node.position.end.offset;
export { locEnd, locStart };

View File

@@ -0,0 +1,83 @@
/**
* modified from https://github.com/mdx-js/mdx/blob/c91b00c673bcf3e7c28b861fd692b69016026c45/packages/remark-mdx/index.js
*
* The MIT License (MIT)
*
* Copyright (c) 2017-2018 Compositor and Zeit, Inc.
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
const IMPORT_REGEX = /^import\s/u;
const EXPORT_REGEX = /^export\s/u;
const BLOCKS_REGEX = String.raw`[a-z][a-z0-9]*(\.[a-z][a-z0-9]*)*|`;
const COMMENT_REGEX = /<!---->|<!---?[^>-](?:-?[^-])*-->/u;
const ES_COMMENT_REGEX = /^\{\s*\/\*(.*)\*\/\s*\}/u;
const EMPTY_NEWLINE = "\n\n";
const isImport = (text) => IMPORT_REGEX.test(text);
const isExport = (text) => EXPORT_REGEX.test(text);
const isImportOrExport = (text) => isImport(text) || isExport(text);
const tokenizeEsSyntax = (eat, value) => {
const index = value.indexOf(EMPTY_NEWLINE);
const subvalue = index === -1 ? value : value.slice(0, index);
if (isImportOrExport(subvalue)) {
return eat(subvalue)({
type: isExport(subvalue) ? "export" : "import",
value: subvalue,
});
}
};
tokenizeEsSyntax.notInBlock = true;
tokenizeEsSyntax.locator = (value /* , fromIndex*/) =>
isImportOrExport(value) ? -1 : 1;
const tokenizeEsComment = (eat, value) => {
const match = ES_COMMENT_REGEX.exec(value);
if (match) {
return eat(match[0])({
type: "esComment",
value: match[1].trim(),
});
}
};
tokenizeEsComment.locator = (value, fromIndex) => value.indexOf("{", fromIndex);
/** @import {Plugin, Settings} from "unified" */
/**
* @type {Plugin<[], Settings>}
*/
const esSyntax = function () {
const { Parser } = this;
const { blockTokenizers, blockMethods, inlineTokenizers, inlineMethods } =
Parser.prototype;
blockTokenizers.esSyntax = tokenizeEsSyntax;
inlineTokenizers.esComment = tokenizeEsComment;
blockMethods.splice(blockMethods.indexOf("paragraph"), 0, "esSyntax");
inlineMethods.splice(inlineMethods.indexOf("text"), 0, "esComment");
};
export { BLOCKS_REGEX, COMMENT_REGEX, esSyntax };

View File

@@ -0,0 +1,20 @@
// Options for markdown formatting
// These match Prettier's default markdown options
// Update this file if Prettier's markdown options change
const options = {
proseWrap: {
type: "choice",
default: "preserve",
choices: [
{ value: "always", description: "Wrap prose if it exceeds the print width" },
{ value: "never", description: "Don't wrap prose" },
{ value: "preserve", description: "Preserve the original wrapping" },
],
},
singleQuote: {
type: "boolean",
default: false,
},
};
export default options;

View File

@@ -0,0 +1,57 @@
import footnotes from "remark-footnotes";
import remarkMath from "remark-math";
import remarkParse from "remark-parse";
import unified from "unified";
import { locEnd, locStart } from "./loc.js";
import { BLOCKS_REGEX, esSyntax } from "./mdx.js";
import { hasIgnorePragma, hasPragma } from "./pragma.js";
import frontMatter from "./unified-plugins/front-matter.js";
import htmlToJsx from "./unified-plugins/html-to-jsx.js";
import liquid from "./unified-plugins/liquid.js";
import wikiLink from "./unified-plugins/wiki-link.js";
/**
* based on [MDAST](https://github.com/syntax-tree/mdast) with following modifications:
*
* 1. restore unescaped character (Text)
* 2. merge continuous Texts
* 3. replace whitespaces in InlineCode#value with one whitespace
* reference: http://spec.commonmark.org/0.25/#example-605
* 4. split Text into Sentence
*
* interface Word { value: string }
* interface Whitespace { value: string }
* interface Sentence { children: Array<Word | Whitespace> }
* interface InlineCode { children: Array<Sentence> }
*/
function createParse({ isMDX }) {
return (text) => {
const processor = unified()
.use(remarkParse, {
commonmark: true,
...(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));
};
}
function noop() {}
const baseParser = {
astFormat: "mdast",
hasPragma,
hasIgnorePragma,
locStart,
locEnd,
};
export const markdown = { ...baseParser, parse: createParse({ isMDX: false }) };
export const mdx = { ...baseParser, parse: createParse({ isMDX: true }) };
export { markdown as remark };

View File

@@ -0,0 +1,25 @@
import { parseFrontMatter } from "../main/front-matter/index.js";
import {
FORMAT_PRAGMA_TO_INSERT,
MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP,
MARKDOWN_HAS_PRAGMA_REGEXP,
} from "../utils/pragma/pragma.evaluate.js";
const hasPragma = (text) =>
parseFrontMatter(text).content.trimStart().match(MARKDOWN_HAS_PRAGMA_REGEXP)
?.index === 0;
const hasIgnorePragma = (text) =>
parseFrontMatter(text)
.content.trimStart()
.match(MARKDOWN_HAS_IGNORE_PRAGMA_REGEXP)?.index === 0;
const insertPragma = (text) => {
const { frontMatter } = parseFrontMatter(text);
const pragma = `<!-- @${FORMAT_PRAGMA_TO_INSERT} -->`;
return frontMatter
? `${frontMatter.raw}\n\n${pragma}\n\n${text.slice(frontMatter.end.index)}`
: `${pragma}\n\n${text}`;
};
export { hasIgnorePragma, hasPragma, insertPragma };

View File

@@ -0,0 +1,55 @@
import { fill } from "../document/builders.js";
import { DOC_TYPE_ARRAY, DOC_TYPE_FILL } from "../document/constants.js";
import { getDocType } from "../document/utils.js";
/**
* @import AstPath from "../common/ast-path.js"
* @import {Doc} from "../document/builders.js"
*/
/**
* @param {AstPath} path
* @param {*} options
* @param {*} print
* @returns {Doc}
*/
function printParagraph(path, options, print) {
const parts = path.map(print, "children");
return flattenFill(parts);
}
/**
* @param {Doc[]} docs
* @returns {Doc}
*/
function flattenFill(docs) {
/*
* We assume parts always meet following conditions:
* - parts.length is odd
* - odd elements are line-like doc that comes from odd element off inner fill
*/
/** @type {Doc[]} */
const parts = [""];
(function rec(/** @type {*} */ docArray) {
for (const doc of docArray) {
const docType = getDocType(doc);
if (docType === DOC_TYPE_ARRAY) {
rec(doc);
continue;
}
let head = doc;
let rest = [];
if (docType === DOC_TYPE_FILL) {
[head, ...rest] = doc.parts;
}
parts.push([parts.pop(), head], ...rest);
}
})(docs);
return fill(parts);
}
export { printParagraph };

View File

@@ -0,0 +1,256 @@
import htmlWhitespaceUtils from "../utils/html-whitespace-utils.js";
import { getOrderedListItemInfo, mapAst, splitText } from "./utils.js";
// 0x0 ~ 0x10ffff
const isSingleCharRegex = /^\\?.$/su;
const isNewLineBlockquoteRegex = /^\n *>[ >]*$/u;
function preprocess(ast, options) {
ast = restoreUnescapedCharacter(ast, options);
ast = mergeContinuousTexts(ast);
ast = transformIndentedCodeblockAndMarkItsParentList(ast, options);
ast = markAlignedList(ast, options);
ast = splitTextIntoSentences(ast);
return ast;
}
function restoreUnescapedCharacter(ast, options) {
return mapAst(ast, (node) => {
if (node.type !== "text") {
return node;
}
const { value } = node;
if (
value === "*" ||
value === "_" || // handle these cases in printer
!isSingleCharRegex.test(value) ||
node.position.end.offset - node.position.start.offset === value.length
) {
return node;
}
const text = options.originalText.slice(
node.position.start.offset,
node.position.end.offset,
);
if (isNewLineBlockquoteRegex.test(text)) {
return node;
}
return { ...node, value: text };
});
}
function mergeChildren(ast, shouldMerge, mergeNode) {
return mapAst(ast, (node) => {
if (!node.children) {
return node;
}
const children = [];
let lastChild;
let changed;
for (let child of node.children) {
if (lastChild && shouldMerge(lastChild, child)) {
child = mergeNode(lastChild, child);
// Replace the previous node
children.splice(-1, 1, child);
changed ||= true;
} else {
children.push(child);
}
lastChild = child;
}
return changed ? { ...node, children } : node;
});
}
function mergeContinuousTexts(ast) {
return mergeChildren(
ast,
(prevNode, node) => prevNode.type === "text" && node.type === "text",
(prevNode, node) => ({
type: "text",
value: prevNode.value + node.value,
position: {
start: prevNode.position.start,
end: node.position.end,
},
}),
);
}
function splitTextIntoSentences(ast) {
return mapAst(ast, (node, index, [parentNode]) => {
if (node.type !== "text") {
return node;
}
let { value } = node;
if (parentNode.type === "paragraph") {
// CommonMark doesn't remove trailing/leading \f, but it should be
// removed in the HTML rendering process
if (index === 0) {
value = htmlWhitespaceUtils.trimStart(value);
}
if (index === parentNode.children.length - 1) {
value = htmlWhitespaceUtils.trimEnd(value);
}
}
return {
type: "sentence",
position: node.position,
children: splitText(value),
};
});
}
function transformIndentedCodeblockAndMarkItsParentList(ast, options) {
return mapAst(ast, (node, index, parentStack) => {
if (node.type === "code") {
// the first char may point to `\n`, e.g. `\n\t\tbar`, just ignore it
const isIndented = /^\n?(?: {4,}|\t)/u.test(
options.originalText.slice(
node.position.start.offset,
node.position.end.offset,
),
);
node.isIndented = isIndented;
if (isIndented) {
for (let i = 0; i < parentStack.length; i++) {
const parent = parentStack[i];
// no need to check checked items
if (parent.hasIndentedCodeblock) {
break;
}
if (parent.type === "list") {
parent.hasIndentedCodeblock = true;
}
}
}
}
return node;
});
}
function markAlignedList(ast, options) {
return mapAst(ast, (node, index, parentStack) => {
if (node.type === "list" && node.children.length > 0) {
// if one of its parents is not aligned, it's not possible to be aligned in sub-lists
for (let i = 0; i < parentStack.length; i++) {
const parent = parentStack[i];
if (parent.type === "list" && !parent.isAligned) {
node.isAligned = false;
return node;
}
}
node.isAligned = isAligned(node);
}
return node;
});
function getListItemStart(listItem) {
return listItem.children.length === 0
? -1
: listItem.children[0].position.start.column - 1;
}
function isAligned(list) {
if (!list.ordered) {
/**
* - 123
* - 123
*/
return true;
}
const [firstItem, secondItem] = list.children;
const firstInfo = getOrderedListItemInfo(firstItem, options);
if (firstInfo.leadingSpaces.length > 1) {
/**
* 1. 123
*
* 1. 123
* 1. 123
*/
return true;
}
const firstStart = getListItemStart(firstItem);
if (firstStart === -1) {
/**
* 1.
*
* 1.
* 1.
*/
return false;
}
if (list.children.length === 1) {
/**
* aligned:
*
* 11. 123
*
* not aligned:
*
* 1. 123
*/
return firstStart % options.tabWidth === 0;
}
const secondStart = getListItemStart(secondItem);
if (firstStart !== secondStart) {
/**
* 11. 123
* 1. 123
*
* 1. 123
* 11. 123
*/
return false;
}
if (firstStart % options.tabWidth === 0) {
/**
* 11. 123
* 12. 123
*/
return true;
}
/**
* aligned:
*
* 11. 123
* 1. 123
*
* not aligned:
*
* 1. 123
* 2. 123
*/
const secondInfo = getOrderedListItemInfo(secondItem, options);
return secondInfo.leadingSpaces.length > 1;
}
}
export default preprocess;

View File

@@ -0,0 +1,37 @@
/**
* @import AstPath from "../common/ast-path.js"
* @import {Doc} from "../document/builders.js"
*/
import { fill } from "../document/builders.js";
import { DOC_TYPE_STRING } from "../document/constants.js";
import { getDocType } from "../document/utils.js";
/**
* @param {AstPath} path
* @param {*} print
* @returns {Doc}
*/
function printSentence(path, print) {
/** @type {Doc[]} */
const parts = [""];
path.each(() => {
const { node } = path;
const doc = print();
switch (node.type) {
case "whitespace":
if (getDocType(doc) !== DOC_TYPE_STRING) {
parts.push(doc, "");
break;
}
// fallthrough
default:
parts.push([parts.pop(), doc]);
}
}, "children");
return fill(parts);
}
export { printSentence };

View File

@@ -0,0 +1,266 @@
import { hardline, line, softline } from "../document/builders.js";
import {
KIND_CJ_LETTER,
KIND_CJK_PUNCTUATION,
KIND_K_LETTER,
KIND_NON_CJK,
} from "./utils.js";
/**
* @import {WordNode, WhitespaceValue, WordKind} from "./utils.js"
* @import AstPath from "../common/ast-path.js"
* @typedef {"always" | "never" | "preserve"} ProseWrap
* @typedef {{ next?: WordNode | null, previous?: WordNode | null }}
* AdjacentNodes Nodes adjacent to a `whitespace` node. Are always of type
* `word`.
*/
const SINGLE_LINE_NODE_TYPES = new Set([
"heading",
"tableCell",
"link",
"wikiLink",
]);
/**
* A line break between a character from this set and CJ can be converted to a
* space. Includes only ASCII punctuation marks for now.
*/
const lineBreakBetweenTheseAndCJConvertsToSpace = new Set(
"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~",
);
/**
* Determine the preferred style of spacing between Chinese or Japanese and non-CJK
* characters in the parent `sentence` node.
*
* @param {AstPath} path
* @returns {boolean} `true` if Space tends to be inserted between CJ and
* non-CJK, `false` otherwise.
*/
function isInSentenceWithCJSpaces({ parent: sentenceNode }) {
if (sentenceNode.usesCJSpaces === undefined) {
const stats = { " ": 0, "": 0 };
const { children } = sentenceNode;
for (let i = 1; i < children.length - 1; ++i) {
const node = children[i];
if (
node.type === "whitespace" &&
(node.value === " " || node.value === "")
) {
const previousKind = children[i - 1].kind;
const nextKind = children[i + 1].kind;
if (
(previousKind === KIND_CJ_LETTER && nextKind === KIND_NON_CJK) ||
(previousKind === KIND_NON_CJK && nextKind === KIND_CJ_LETTER)
) {
++stats[node.value];
}
}
}
// Inject a property to cache the result.
sentenceNode.usesCJSpaces = stats[" "] > stats[""];
}
return sentenceNode.usesCJSpaces;
}
/**
* Check whether the given `"\n"` node can be converted to a space.
*
* For example, if you would like to squash English text
*
* "You might want\nto use Prettier."
*
* into a single line, you would replace `"\n"` with `" "`:
*
* "You might want to use Prettier."
*
* However, Chinese and Japanese don't use U+0020 Space to divide words, so line
* breaks shouldn't be replaced with spaces for those languages.
*
* PRs are welcome to support line breaking rules for other languages.
*
* @param {AstPath} path
* @param {boolean} isLink
* @returns {boolean}
*/
function lineBreakCanBeConvertedToSpace(path, isLink) {
if (isLink) {
return true;
}
/** @type {AdjacentNodes} */
const { previous, next } = path;
// e.g. " \nletter"
if (!previous || !next) {
return true;
}
const previousKind = previous.kind;
const nextKind = next.kind;
if (
// "\n" between non-CJK or Korean characters always can be converted to a
// space. Korean Hangul simulates Latin words. See
// https://github.com/prettier/prettier/issues/6516
(isNonCJKOrKoreanLetter(previousKind) &&
isNonCJKOrKoreanLetter(nextKind)) ||
// Han & Hangul: same way preferred
(previousKind === KIND_K_LETTER && nextKind === KIND_CJ_LETTER) ||
(nextKind === KIND_K_LETTER && previousKind === KIND_CJ_LETTER)
) {
return true;
}
// Do not convert \n to a space:
if (
// around CJK punctuation
previousKind === KIND_CJK_PUNCTUATION ||
nextKind === KIND_CJK_PUNCTUATION ||
// between CJ
(previousKind === KIND_CJ_LETTER && nextKind === KIND_CJ_LETTER)
) {
return false;
}
// The rest of this function deals only with line breaks between CJ and
// non-CJK characters.
// Convert a line break between CJ and certain non-letter characters (e.g.
// ASCII punctuation) to a space.
//
// E.g. :::\n句子句子句子\n::: → ::: 句子句子句子 :::
//
// Note: line breaks like "(\n句子句子\n)" or "句子\n." are suppressed in
// `isBreakable(...)`.
if (
lineBreakBetweenTheseAndCJConvertsToSpace.has(next.value[0]) ||
lineBreakBetweenTheseAndCJConvertsToSpace.has(previous.value.at(-1))
) {
return true;
}
// Converting a line break between CJ and non-ASCII punctuation to a space is
// undesired in many cases. PRs are welcome to fine-tune this logic.
//
// Examples where \n must not be converted to a space:
//
// 1. "〜" (U+301C, belongs to Pd) in
//
// "ア〜\nエの中から1つ選べ。"
//
// 2. "…" (U+2026, belongs to Po) in
//
// "これはひどい……\nなんと汚いコミットログなんだ……"
if (previous.hasTrailingPunctuation || next.hasLeadingPunctuation) {
return false;
}
// If the sentence uses the style with spaces between CJ and non-CJK, "\n" can
// be converted to a space.
return isInSentenceWithCJSpaces(path);
}
/**
* @param {WordKind | undefined} kind
* @returns {boolean} `true` if `kind` is Korean letter or non-CJK
*/
function isNonCJKOrKoreanLetter(kind) {
return kind === KIND_NON_CJK || kind === KIND_K_LETTER;
}
/**
* Check whether whitespace can be printed as a line break.
*
* @param {AstPath} path
* @param {WhitespaceValue} value
* @param {ProseWrap} proseWrap
* @param {boolean} isLink
* @returns {boolean}
*/
function isBreakable(path, value, proseWrap, isLink) {
if (
proseWrap !== "always" ||
path.hasAncestor((node) => SINGLE_LINE_NODE_TYPES.has(node.type))
) {
return false;
}
if (isLink) {
return value !== "";
}
/** @type {AdjacentNodes} */
const { previous, next } = path;
// [1]: We will make a breaking change to the rule to convert spaces between
// a Chinese or Japanese character and another character in the future.
// Such a space must have been always interchangeable with a line break.
// https://wpt.fyi/results/css/css-text/line-breaking?label=master&label=experimental&aligned&q=segment-break-transformation-rules-
// [2]: we should not break lines even between Chinese/Japanese characters because Chrome & Safari replaces "\n" between such characters with " " now.
// [3]: Hangul (Korean) must simulate Latin words; see https://github.com/prettier/prettier/issues/6516
// [printable][""][Hangul] & vice versa => Don't break
// [printable][\n][Hangul] will be interchangeable to [printable][" "][Hangul] in the future
// (will be compatible with Firefox's behavior)
if (!previous || !next) {
// empty side is Latin ASCII symbol (e.g. *, [, ], or `)
// value is " " or "\n" (not "")
// [1] & [2]? No, it's the only exception because " " & "\n" have been always interchangeable only here
return true;
}
if (value === "") {
// [1] & [2] & [3]
// At least either of previous or next is non-Latin (=CJK)
return false;
}
if (
// See the same product terms as the following in lineBreakCanBeConvertedToSpace
// The behavior is consistent between browsers and Prettier in that line breaks between Korean and Chinese/Japanese letters are equivalent to spaces.
// Currently, [CJK punctuation][\n][Hangul] is interchangeable to [CJK punctuation][""][Hangul],
// but this is not compatible with Firefox's behavior.
// Will be changed to [CJK punctuation][" "][Hangul] in the future
(previous.kind === KIND_K_LETTER && next.kind === KIND_CJ_LETTER) ||
(next.kind === KIND_K_LETTER && previous.kind === KIND_CJ_LETTER)
) {
return true;
}
// [1] & [2]
if (previous.isCJ || next.isCJ) {
return false;
}
return true;
}
/**
* @param {AstPath} path
* @param {WhitespaceValue} value
* @param {ProseWrap} proseWrap
* @param {boolean} [isLink] Special mode of (un)wrapping that preserves the
* normalized form of link labels. https://spec.commonmark.org/0.30/#matches
*/
function printWhitespace(path, value, proseWrap, isLink) {
if (proseWrap === "preserve" && value === "\n") {
return hardline;
}
const canBeSpace =
value === " " ||
(value === "\n" && lineBreakCanBeConvertedToSpace(path, isLink));
if (isBreakable(path, value, proseWrap, isLink)) {
return canBeSpace ? line : softline;
}
return canBeSpace ? " " : "";
}
export { printWhitespace };

View File

@@ -0,0 +1,81 @@
import {
breakParent,
group,
hardlineWithoutBreakParent,
ifBreak,
join,
} from "../../document/builders.js";
import { printDocToString } from "../../document/printer.js";
import getStringWidth from "../../utils/get-string-width.js";
function printTable(path, options, print) {
const { node } = path;
const columnMaxWidths = [];
// { [rowIndex: number]: { [columnIndex: number]: {text: string, width: number} } }
const contents = path.map(
() =>
path.map(({ index: columnIndex }) => {
const text = printDocToString(print(), options).formatted;
const width = getStringWidth(text);
columnMaxWidths[columnIndex] = Math.max(
columnMaxWidths[columnIndex] ?? 3, // minimum width = 3 (---, :--, :-:, --:)
width,
);
return { text, width };
}, "children"),
"children",
);
const alignedTable = printTableContents(/* isCompact */ false);
if (options.proseWrap !== "never") {
return [breakParent, alignedTable];
}
// Only if the --prose-wrap never is set and it exceeds the print width.
const compactTable = printTableContents(/* isCompact */ true);
return [breakParent, group(ifBreak(compactTable, alignedTable))];
function printTableContents(isCompact) {
return join(
hardlineWithoutBreakParent,
[
printRow(contents[0], isCompact),
printAlign(isCompact),
...contents
.slice(1)
.map((rowContents) => printRow(rowContents, isCompact)),
].map((columns) => `| ${columns.join(" | ")} |`),
);
}
function printAlign(isCompact) {
return columnMaxWidths.map((width, index) => {
const align = node.align[index];
const first = align === "center" || align === "left" ? ":" : "-";
const last = align === "center" || align === "right" ? ":" : "-";
const middle = isCompact ? "-" : "-".repeat(width - 2);
return `${first}${middle}${last}`;
});
}
function printRow(columns, isCompact) {
return columns.map(({ text, width }, columnIndex) => {
if (isCompact) {
return text;
}
const spaces = columnMaxWidths[columnIndex] - width;
const align = node.align[columnIndex];
let before = 0;
if (align === "right") {
before = spaces;
} else if (align === "center") {
before = Math.floor(spaces / 2);
}
const after = spaces - before;
return `${" ".repeat(before)}${text}${" ".repeat(after)}`;
});
}
}
export { printTable };

View File

@@ -0,0 +1,810 @@
import collapseWhiteSpace from "collapse-white-space";
import escapeStringRegexp from "escape-string-regexp";
import {
align,
fill,
group,
hardline,
indent,
line,
literalline,
markAsRoot,
softline,
} from "./adapter-document-builders.js";
import { DOC_TYPE_STRING } from "./adapter-document-constants.js";
import { getDocType, replaceEndOfLine } from "./adapter-document-utils.js";
import {
getMaxContinuousCount,
getMinNotPresentContinuousCount,
getPreferredQuote,
UnexpectedNodeError,
} from "./adapter-prettier-utils.js";
import clean from "./clean.js";
import { PUNCTUATION_REGEXP } from "./constants.evaluate.js";
import embed from "./embed.js";
import getVisitorKeys from "./get-visitor-keys.js";
import { locEnd, locStart } from "./loc.js";
import { insertPragma } from "./pragma.js";
import { printTable } from "./print/table.js";
import { printParagraph } from "./print-paragraph.js";
import preprocess from "./print-preprocess.js";
import { printSentence } from "./print-sentence.js";
import { printWhitespace } from "./print-whitespace.js";
import {
getFencedCodeBlockValue,
hasGitDiffFriendlyOrderedList,
INLINE_NODE_TYPES,
INLINE_NODE_WRAPPER_TYPES,
isAutolink,
splitText,
} from "./utils.js";
/**
* @import {Doc} from "../document/builders.js"
*/
const SIBLING_NODE_TYPES = new Set(["listItem", "definition"]);
function prevOrNextWord(path) {
const { previous, next } = path;
const hasPrevOrNextWord =
(previous?.type === "sentence" &&
previous.children.at(-1)?.type === "word" &&
!previous.children.at(-1).hasTrailingPunctuation) ||
(next?.type === "sentence" &&
next.children[0]?.type === "word" &&
!next.children[0].hasLeadingPunctuation);
return hasPrevOrNextWord;
}
function genericPrint(path, options, print) {
const { node } = path;
if (shouldRemainTheSameContent(path)) {
/*
* We assume parts always meet following conditions:
* - parts.length is odd
* - odd (0-indexed) elements are line-like doc
*/
/** @type {Doc[]} */
const parts = [""];
const textsNodes = splitText(
options.originalText.slice(
node.position.start.offset,
node.position.end.offset,
),
);
for (const node of textsNodes) {
if (node.type === "word") {
parts.push([parts.pop(), node.value]);
continue;
}
const doc = printWhitespace(path, node.value, options.proseWrap, true);
if (getDocType(doc) === DOC_TYPE_STRING) {
parts.push([parts.pop(), doc]);
continue;
}
// In this path, doc is line. To meet the condition, we need additional element "".
parts.push(doc, "");
}
return fill(parts);
}
switch (node.type) {
case "root":
/* c8 ignore next 3 */
if (node.children.length === 0) {
return "";
}
return [printRoot(path, options, print), hardline];
case "paragraph":
return printParagraph(path, options, print);
case "sentence":
return printSentence(path, print);
case "word": {
let escapedValue = node.value
.replaceAll("*", String.raw`\*`) // escape all `*`
.replaceAll(
new RegExp(
[
`(^|${PUNCTUATION_REGEXP.source})(_+)`,
`(_+)(${PUNCTUATION_REGEXP.source}|$)`,
].join("|"),
"gu",
),
(_, text1, underscore1, underscore2, text2) =>
(underscore1
? `${text1}${underscore1}`
: `${underscore2}${text2}`
).replaceAll("_", String.raw`\_`),
); // escape all `_` except concating with non-punctuation, e.g. `1_2_3` is not considered emphasis
const isFirstSentence = (node, name, index) =>
node.type === "sentence" && index === 0;
const isLastChildAutolink = (node, name, index) =>
isAutolink(node.children[index - 1]);
if (
escapedValue !== node.value &&
(path.match(undefined, isFirstSentence, isLastChildAutolink) ||
path.match(
undefined,
isFirstSentence,
(node, name, index) => node.type === "emphasis" && index === 0,
isLastChildAutolink,
))
) {
// backslash is parsed as part of autolinks, so we need to remove it
escapedValue = escapedValue.replace(/^(\\?[*_])+/u, (prefix) =>
prefix.replaceAll("\\", ""),
);
}
return escapedValue;
}
case "whitespace": {
const { next } = path;
const proseWrap =
// leading char that may cause different syntax
next && /^>|^(?:[*+-]|#{1,6}|\d+[).])$/u.test(next.value)
? "never"
: options.proseWrap;
return printWhitespace(path, node.value, proseWrap);
}
case "emphasis": {
let style;
if (isAutolink(node.children[0])) {
style = options.originalText[node.position.start.offset];
} else {
const hasPrevOrNextWord = prevOrNextWord(path); // `1*2*3` is considered emphasis but `1_2_3` is not
const inStrongAndHasPrevOrNextWord = // `1***2***3` is considered strong emphasis but `1**_2_**3` is not
path.callParent(
({ node }) => node.type === "strong" && prevOrNextWord(path),
);
style =
hasPrevOrNextWord ||
inStrongAndHasPrevOrNextWord ||
path.hasAncestor((node) => node.type === "emphasis")
? "*"
: "_";
}
return [style, printChildren(path, options, print), style];
}
case "strong":
return ["**", printChildren(path, options, print), "**"];
case "delete":
return ["~~", printChildren(path, options, print), "~~"];
case "inlineCode": {
const code =
options.proseWrap === "preserve"
? node.value
: node.value.replaceAll("\n", " ");
const backtickCount = getMinNotPresentContinuousCount(code, "`");
const backtickString = "`".repeat(backtickCount);
const padding =
code.startsWith("`") ||
code.endsWith("`") ||
(/^[\n ]/u.test(code) && /[\n ]$/u.test(code) && /[^\n ]/u.test(code))
? " "
: "";
return [backtickString, padding, code, padding, backtickString];
}
case "wikiLink": {
let contents = "";
if (options.proseWrap === "preserve") {
contents = node.value;
} else {
contents = node.value.replaceAll(/[\t\n]+/gu, " ");
}
return ["[[", contents, "]]"];
}
case "link":
switch (options.originalText[node.position.start.offset]) {
case "<": {
const mailto = "mailto:";
const url =
// <hello@example.com> is parsed as { url: "mailto:hello@example.com" }
node.url.startsWith(mailto) &&
options.originalText.slice(
node.position.start.offset + 1,
node.position.start.offset + 1 + mailto.length,
) !== mailto
? node.url.slice(mailto.length)
: node.url;
return ["<", url, ">"];
}
case "[":
return [
"[",
printChildren(path, options, print),
"](",
printUrl(node.url, ")"),
printTitle(node.title, options),
")",
];
default:
return options.originalText.slice(
node.position.start.offset,
node.position.end.offset,
);
}
case "image":
return [
"![",
node.alt || "",
"](",
printUrl(node.url, ")"),
printTitle(node.title, options),
")",
];
case "blockquote":
return ["> ", align("> ", printChildren(path, options, print))];
case "heading":
return [
"#".repeat(node.depth) + " ",
printChildren(path, options, print),
];
case "code": {
if (node.isIndented) {
// indented code block
const alignment = " ".repeat(4);
return align(alignment, [
alignment,
replaceEndOfLine(node.value, hardline),
]);
}
// fenced code block
const styleUnit = options.__inJsTemplate ? "~" : "`";
const style = styleUnit.repeat(
Math.max(3, getMaxContinuousCount(node.value, styleUnit) + 1),
);
return [
style,
node.lang || "",
node.meta ? " " + node.meta : "",
hardline,
replaceEndOfLine(
getFencedCodeBlockValue(node, options.originalText),
hardline,
),
hardline,
style,
];
}
case "html": {
const { parent, isLast } = path;
const value =
parent.type === "root" && isLast ? node.value.trimEnd() : node.value;
const isHtmlComment = /^<!--.*-->$/su.test(value);
return replaceEndOfLine(
value,
// @ts-expect-error
isHtmlComment ? hardline : markAsRoot(literalline),
);
}
case "list": {
const nthSiblingIndex = getNthListSiblingIndex(node, path.parent);
const isGitDiffFriendlyOrderedList = hasGitDiffFriendlyOrderedList(
node,
options,
);
return printChildren(path, options, print, {
processor() {
const prefix = getPrefix();
const { node: childNode } = path;
if (
childNode.children.length === 2 &&
childNode.children[1].type === "html" &&
childNode.children[0].position.start.column !==
childNode.children[1].position.start.column
) {
return [prefix, printListItem(path, options, print, prefix)];
}
return [
prefix,
align(
" ".repeat(prefix.length),
printListItem(path, options, print, prefix),
),
];
function getPrefix() {
const rawPrefix = node.ordered
? (path.isFirst
? node.start
: isGitDiffFriendlyOrderedList
? 1
: node.start + path.index) +
(nthSiblingIndex % 2 === 0 ? ". " : ") ")
: nthSiblingIndex % 2 === 0
? "- "
: "* ";
return (node.isAligned ||
/* workaround for https://github.com/remarkjs/remark/issues/315 */ node.hasIndentedCodeblock) &&
node.ordered
? alignListPrefix(rawPrefix, options)
: rawPrefix;
}
},
});
}
case "thematicBreak": {
const { ancestors } = path;
const counter = ancestors.findIndex((node) => node.type === "list");
if (counter === -1) {
return "---";
}
const nthSiblingIndex = getNthListSiblingIndex(
ancestors[counter],
ancestors[counter + 1],
);
return nthSiblingIndex % 2 === 0 ? "***" : "---";
}
case "linkReference":
return [
"[",
printChildren(path, options, print),
"]",
node.referenceType === "full"
? printLinkReference(node)
: node.referenceType === "collapsed"
? "[]"
: "",
];
case "imageReference":
switch (node.referenceType) {
case "full":
return ["![", node.alt || "", "]", printLinkReference(node)];
default:
return [
"![",
node.alt,
"]",
node.referenceType === "collapsed" ? "[]" : "",
];
}
case "definition": {
const lineOrSpace = options.proseWrap === "always" ? line : " ";
return group([
printLinkReference(node),
":",
indent([
lineOrSpace,
printUrl(node.url),
node.title === null
? ""
: [lineOrSpace, printTitle(node.title, options, false)],
]),
]);
}
// `footnote` requires `.use(footnotes, {inlineNotes: true})`, we are not using this option
// https://github.com/remarkjs/remark-footnotes#optionsinlinenotes
/* c8 ignore next 2 */
case "footnote":
return ["[^", printChildren(path, options, print), "]"];
case "footnoteReference":
return printFootnoteReference(node);
case "footnoteDefinition": {
const shouldInlineFootnote =
node.children.length === 1 &&
node.children[0].type === "paragraph" &&
(options.proseWrap === "never" ||
(options.proseWrap === "preserve" &&
node.children[0].position.start.line ===
node.children[0].position.end.line));
return [
printFootnoteReference(node),
": ",
shouldInlineFootnote
? printChildren(path, options, print)
: group([
align(
" ".repeat(4),
printChildren(path, options, print, {
processor: ({ isFirst }) =>
isFirst ? group([softline, print()]) : print(),
}),
),
]),
];
}
case "table":
return printTable(path, options, print);
case "tableCell":
return printChildren(path, options, print);
case "break":
return /\s/u.test(options.originalText[node.position.start.offset])
? [" ", markAsRoot(literalline)]
: ["\\", hardline];
case "liquidNode":
return replaceEndOfLine(node.value, hardline);
// MDX
// fallback to the original text if multiparser failed
// or `embeddedLanguageFormatting: "off"`
case "import":
case "export":
case "jsx":
return node.value.trimEnd();
case "esComment":
return ["{/* ", node.value, " */}"];
case "math":
return [
"$$",
hardline,
node.value ? [replaceEndOfLine(node.value, hardline), hardline] : "",
"$$",
];
case "inlineMath":
// remark-math trims content but we don't want to remove whitespaces
// since it's very possible that it's recognized as math accidentally
return options.originalText.slice(locStart(node), locEnd(node));
case "frontMatter": // Handled in core
case "tableRow": // handled in "table"
case "listItem": // handled in "list"
case "text": // handled in other types
default:
/* c8 ignore next */
throw new UnexpectedNodeError(node, "Markdown");
}
}
function printListItem(path, options, print, listPrefix) {
const { node } = path;
const prefix = node.checked === null ? "" : node.checked ? "[x] " : "[ ] ";
return [
prefix,
printChildren(path, options, print, {
processor({ node, isFirst }) {
if (isFirst && node.type !== "list") {
return align(" ".repeat(prefix.length), print());
}
const alignment = " ".repeat(
clamp(options.tabWidth - listPrefix.length, 0, 3), // 4+ will cause indented code block
);
return [alignment, align(alignment, print())];
},
}),
];
}
function alignListPrefix(prefix, options) {
const additionalSpaces = getAdditionalSpaces();
return (
prefix +
" ".repeat(
additionalSpaces >= 4 ? 0 : additionalSpaces, // 4+ will cause indented code block
)
);
function getAdditionalSpaces() {
const restSpaces = prefix.length % options.tabWidth;
return restSpaces === 0 ? 0 : options.tabWidth - restSpaces;
}
}
function getNthListSiblingIndex(node, parentNode) {
return getNthSiblingIndex(
node,
parentNode,
(siblingNode) => siblingNode.ordered === node.ordered,
);
}
function getNthSiblingIndex(node, parentNode, condition) {
let index = -1;
for (const childNode of parentNode.children) {
if (childNode.type === node.type && condition(childNode)) {
index++;
} else {
index = -1;
}
if (childNode === node) {
return index;
}
}
}
function printRoot(path, options, print) {
/** @typedef {{ index: number, offset: number }} IgnorePosition */
/** @type {Array<{start: IgnorePosition, end: IgnorePosition}>} */
const ignoreRanges = [];
/** @type {IgnorePosition | null} */
let ignoreStart = null;
const { children } = path.node;
for (const [index, childNode] of children.entries()) {
switch (isPrettierIgnore(childNode)) {
case "start":
if (ignoreStart === null) {
ignoreStart = { index, offset: childNode.position.end.offset };
}
break;
case "end":
if (ignoreStart !== null) {
ignoreRanges.push({
start: ignoreStart,
end: { index, offset: childNode.position.start.offset },
});
ignoreStart = null;
}
break;
default:
// do nothing
break;
}
}
return printChildren(path, options, print, {
processor({ index }) {
if (ignoreRanges.length > 0) {
const ignoreRange = ignoreRanges[0];
if (index === ignoreRange.start.index) {
return [
printIgnoreComment(children[ignoreRange.start.index]),
options.originalText.slice(
ignoreRange.start.offset,
ignoreRange.end.offset,
),
printIgnoreComment(children[ignoreRange.end.index]),
];
}
if (ignoreRange.start.index < index && index < ignoreRange.end.index) {
return false;
}
if (index === ignoreRange.end.index) {
ignoreRanges.shift();
return false;
}
}
return print();
},
});
}
function printChildren(path, options, print, events = {}) {
const { processor = print } = events;
const parts = [];
path.each(() => {
const result = processor(path);
if (result !== false) {
if (parts.length > 0 && shouldPrePrintHardline(path)) {
parts.push(hardline);
if (
shouldPrePrintDoubleHardline(path, options) ||
shouldPrePrintTripleHardline(path)
) {
parts.push(hardline);
}
if (shouldPrePrintTripleHardline(path)) {
parts.push(hardline);
}
}
parts.push(result);
}
}, "children");
return parts;
}
function printIgnoreComment(node) {
if (node.type === "html") {
return node.value;
}
if (
node.type === "paragraph" &&
Array.isArray(node.children) &&
node.children.length === 1 &&
node.children[0].type === "esComment"
) {
return ["{/* ", node.children[0].value, " */}"];
}
}
/** @return {false | 'next' | 'start' | 'end'} */
function isPrettierIgnore(node) {
let match;
if (node.type === "html") {
match = node.value.match(
/^<!--\s*prettier-ignore(?:-(start|end))?\s*-->$/u,
);
} else {
let comment;
if (node.type === "esComment") {
comment = node;
} else if (
node.type === "paragraph" &&
node.children.length === 1 &&
node.children[0].type === "esComment"
) {
comment = node.children[0];
}
if (comment) {
match = comment.value.match(/^prettier-ignore(?:-(start|end))?$/u);
}
}
return match ? match[1] || "next" : false;
}
function shouldPrePrintHardline({ node, parent }) {
const isInlineNode = INLINE_NODE_TYPES.has(node.type);
const isInlineHTML =
node.type === "html" && INLINE_NODE_WRAPPER_TYPES.has(parent.type);
return !isInlineNode && !isInlineHTML;
}
function isLooseListItem(node, options) {
return (
node.type === "listItem" &&
(node.spread ||
// Check if `listItem` ends with `\n`
// since it can't be empty, so we only need check the last character
options.originalText.charAt(node.position.end.offset - 1) === "\n")
);
}
function shouldPrePrintDoubleHardline({ node, previous, parent }, options) {
if (
isLooseListItem(previous, options) ||
(node.type === "list" &&
parent.type === "listItem" &&
previous.type === "code")
) {
return true;
}
const isSequence = previous.type === node.type;
const isSiblingNode = isSequence && SIBLING_NODE_TYPES.has(node.type);
const isInTightListItem =
parent.type === "listItem" &&
(node.type === "list" || !isLooseListItem(parent, options));
const isPrevNodePrettierIgnore = isPrettierIgnore(previous) === "next";
const isBlockHtmlWithoutBlankLineBetweenPrevHtml =
node.type === "html" &&
previous.type === "html" &&
previous.position.end.line + 1 === node.position.start.line;
const isHtmlDirectAfterListItem =
node.type === "html" &&
parent.type === "listItem" &&
previous.type === "paragraph" &&
previous.position.end.line + 1 === node.position.start.line;
return !(
isSiblingNode ||
isInTightListItem ||
isPrevNodePrettierIgnore ||
isBlockHtmlWithoutBlankLineBetweenPrevHtml ||
isHtmlDirectAfterListItem
);
}
function shouldPrePrintTripleHardline({ node, previous }) {
const isPrevNodeList = previous.type === "list";
const isIndentedCode = node.type === "code" && node.isIndented;
return isPrevNodeList && isIndentedCode;
}
function shouldRemainTheSameContent(path) {
const node = path.findAncestor(
(node) => node.type === "linkReference" || node.type === "imageReference",
);
return (
node && (node.type !== "linkReference" || node.referenceType !== "full")
);
}
const encodeUrl = (url, characters) => {
for (const character of characters) {
url = url.replaceAll(character, encodeURIComponent(character));
}
return url;
};
/**
* @param {string} url
* @param {string[] | string} [dangerousCharOrChars]
* @returns {string}
*/
function printUrl(url, dangerousCharOrChars = []) {
const dangerousChars = [
" ",
...(Array.isArray(dangerousCharOrChars)
? dangerousCharOrChars
: [dangerousCharOrChars]),
];
return new RegExp(
dangerousChars.map((x) => escapeStringRegexp(x)).join("|"),
"u",
).test(url)
? `<${encodeUrl(url, "<>")}>`
: url;
}
function printTitle(title, options, printSpace = true) {
if (!title) {
return "";
}
if (printSpace) {
return " " + printTitle(title, options, false);
}
// title is escaped after `remark-parse` v7
title = title.replaceAll(/\\(?=["')])/gu, "");
if (title.includes('"') && title.includes("'") && !title.includes(")")) {
return `(${title})`; // avoid escaped quotes
}
const quote = getPreferredQuote(title, options.singleQuote);
title = title.replaceAll("\\", "\\\\");
title = title.replaceAll(quote, `\\${quote}`);
return `${quote}${title}${quote}`;
}
function clamp(value, min, max) {
return Math.max(min, Math.min(value, max));
}
function hasPrettierIgnore(path) {
return path.index > 0 && isPrettierIgnore(path.previous) === "next";
}
// `remark-parse` lowercase the `label` as `identifier`, we don't want do that
// https://github.com/remarkjs/remark/blob/daddcb463af2d5b2115496c395d0571c0ff87d15/packages/remark-parse/lib/tokenize/reference.js
function printLinkReference(node) {
return `[${collapseWhiteSpace(node.label)}]`;
}
function printFootnoteReference(node) {
return `[^${node.label}]`;
}
const printer = {
features: {
experimental_frontMatterSupport: {
massageAstNode: true,
embed: true,
print: true,
},
},
preprocess,
print: genericPrint,
embed,
massageAstNode: clean,
hasPrettierIgnore,
insertPragma,
getVisitorKeys,
};
export default printer;

View File

@@ -0,0 +1,23 @@
import { parseFrontMatter } from "../../main/front-matter/index.js";
/** @import {Plugin, Settings} from "unified" */
/**
* @type {Plugin<[], Settings>}
*/
const frontMatter = function () {
const proto = this.Parser.prototype;
proto.blockMethods = ["frontMatter", ...proto.blockMethods];
proto.blockTokenizers.frontMatter = tokenizer;
function tokenizer(eat, value) {
const { frontMatter } = parseFrontMatter(value);
if (frontMatter) {
return eat(frontMatter.raw)({ ...frontMatter, type: "frontMatter" });
}
}
tokenizer.onlyAtStart = true;
};
export default frontMatter;

View File

@@ -0,0 +1,19 @@
import { COMMENT_REGEX } from "../mdx.js";
import { INLINE_NODE_WRAPPER_TYPES, mapAst } from "../utils.js";
function htmlToJsx() {
return (ast) =>
mapAst(ast, (node, _index, [parent]) => {
if (
node.type !== "html" ||
// Keep HTML-style comments (legacy MDX)
COMMENT_REGEX.test(node.value) ||
INLINE_NODE_WRAPPER_TYPES.has(parent.type)
) {
return node;
}
return { ...node, type: "jsx" };
});
}
export default htmlToJsx;

View File

@@ -0,0 +1,27 @@
/** @import {Plugin, Settings} from "unified" */
/**
* @type {Plugin<[], Settings>}
*/
const liquid = function () {
const proto = this.Parser.prototype;
const methods = proto.inlineMethods;
methods.splice(methods.indexOf("text"), 0, "liquid");
proto.inlineTokenizers.liquid = tokenizer;
function tokenizer(eat, value) {
const match = value.match(/^(\{%.*?%\}|\{\{.*?\}\})/su);
if (match) {
return eat(match[0])({
type: "liquidNode",
value: match[0],
});
}
}
tokenizer.locator = function (value, fromIndex) {
return value.indexOf("{", fromIndex);
};
};
export default liquid;

View File

@@ -0,0 +1,32 @@
/** @import {Plugin, Settings} from "unified" */
/**
* @type {Plugin<[], Settings>}
*/
const wikiLink = function () {
const entityType = "wikiLink";
const wikiLinkRegex = /^\[\[(?<linkContents>.+?)\]\]/su;
const proto = this.Parser.prototype;
const methods = proto.inlineMethods;
methods.splice(methods.indexOf("link"), 0, entityType);
proto.inlineTokenizers.wikiLink = tokenizer;
function tokenizer(eat, value) {
const match = wikiLinkRegex.exec(value);
if (match) {
const linkContents = match.groups.linkContents.trim();
return eat(match[0])({
type: entityType,
value: linkContents,
});
}
}
tokenizer.locator = function (value, fromIndex) {
return value.indexOf("[", fromIndex);
};
};
export default wikiLink;

View File

@@ -0,0 +1,268 @@
import * as assert from "#universal/assert";
import { CJK_REGEXP, PUNCTUATION_REGEXP } from "./constants.evaluate.js";
import { locEnd, locStart } from "./loc.js";
const INLINE_NODE_TYPES = new Set([
"liquidNode",
"inlineCode",
"emphasis",
"esComment",
"strong",
"delete",
"wikiLink",
"link",
"linkReference",
"image",
"imageReference",
"footnote",
"footnoteReference",
"sentence",
"whitespace",
"word",
"break",
"inlineMath",
]);
const INLINE_NODE_WRAPPER_TYPES = new Set([
...INLINE_NODE_TYPES,
"tableCell",
"paragraph",
"heading",
]);
const KIND_NON_CJK = "non-cjk";
const KIND_CJ_LETTER = "cj-letter";
const KIND_K_LETTER = "k-letter";
const KIND_CJK_PUNCTUATION = "cjk-punctuation";
const K_REGEXP = /\p{Script_Extensions=Hangul}/u;
/**
* @typedef {" " | "\n" | ""} WhitespaceValue
* @typedef { KIND_NON_CJK | KIND_CJ_LETTER | KIND_K_LETTER | KIND_CJK_PUNCTUATION } WordKind
* @typedef {{
* type: "whitespace",
* value: WhitespaceValue,
* kind?: never
* }} WhitespaceNode
* @typedef {{
* type: "word",
* value: string,
* kind: WordKind,
* isCJ: boolean,
* hasLeadingPunctuation: boolean,
* hasTrailingPunctuation: boolean,
* }} WordNode
* Node for a single CJK character or a sequence of non-CJK characters
* @typedef {WhitespaceNode | WordNode} TextNode
*/
/**
* split text into whitespaces and words
* @param {string} text
*/
function splitText(text) {
/** @type {Array<TextNode>} */
const nodes = [];
const tokens = text.split(/([\t\n ]+)/u);
for (const [index, token] of tokens.entries()) {
// whitespace
if (index % 2 === 1) {
nodes.push({
type: "whitespace",
value: /\n/u.test(token) ? "\n" : " ",
});
continue;
}
// word separated by whitespace
if ((index === 0 || index === tokens.length - 1) && token === "") {
continue;
}
const innerTokens = token.split(new RegExp(`(${CJK_REGEXP.source})`, "u"));
for (const [innerIndex, innerToken] of innerTokens.entries()) {
if (
(innerIndex === 0 || innerIndex === innerTokens.length - 1) &&
innerToken === ""
) {
continue;
}
// non-CJK word
if (innerIndex % 2 === 0) {
if (innerToken !== "") {
appendNode({
type: "word",
value: innerToken,
kind: KIND_NON_CJK,
isCJ: false,
hasLeadingPunctuation: PUNCTUATION_REGEXP.test(innerToken[0]),
hasTrailingPunctuation: PUNCTUATION_REGEXP.test(innerToken.at(-1)),
});
}
continue;
}
// CJK character
// punctuation for CJ(K)
// Korean doesn't use them in horizontal writing usually
if (PUNCTUATION_REGEXP.test(innerToken)) {
appendNode({
type: "word",
value: innerToken,
kind: KIND_CJK_PUNCTUATION,
isCJ: true,
hasLeadingPunctuation: true,
hasTrailingPunctuation: true,
});
continue;
}
// Korean uses space to divide words, but Chinese & Japanese do not
// This is why Korean should be treated like non-CJK
if (K_REGEXP.test(innerToken)) {
appendNode({
type: "word",
value: innerToken,
kind: KIND_K_LETTER,
isCJ: false,
hasLeadingPunctuation: false,
hasTrailingPunctuation: false,
});
continue;
}
appendNode({
type: "word",
value: innerToken,
kind: KIND_CJ_LETTER,
isCJ: true,
hasLeadingPunctuation: false,
hasTrailingPunctuation: false,
});
}
}
// Check for `canBeConvertedToSpace` in ./print-whitespace.js etc.
if (process.env.NODE_ENV !== "production") {
for (let i = 1; i < nodes.length; i++) {
assert.ok(
!(nodes[i - 1].type === "whitespace" && nodes[i].type === "whitespace"),
"splitText should not create consecutive whitespace nodes",
);
}
}
return nodes;
function appendNode(node) {
const lastNode = nodes.at(-1);
if (
lastNode?.type === "word" &&
!isBetween(KIND_NON_CJK, KIND_CJK_PUNCTUATION) &&
// disallow leading/trailing full-width whitespace
![lastNode.value, node.value].some((value) => /\u3000/u.test(value))
) {
nodes.push({ type: "whitespace", value: "" });
}
nodes.push(node);
function isBetween(kind1, kind2) {
return (
(lastNode.kind === kind1 && node.kind === kind2) ||
(lastNode.kind === kind2 && node.kind === kind1)
);
}
}
}
function getOrderedListItemInfo(orderListItem, options) {
const text = options.originalText.slice(
orderListItem.position.start.offset,
orderListItem.position.end.offset,
);
const { numberText, leadingSpaces } = text.match(
/^\s*(?<numberText>\d+)(\.|\))(?<leadingSpaces>\s*)/u,
).groups;
return { number: Number(numberText), leadingSpaces };
}
function hasGitDiffFriendlyOrderedList(node, options) {
if (!node.ordered || node.children.length < 2) {
return false;
}
const secondNumber = getOrderedListItemInfo(node.children[1], options).number;
if (secondNumber !== 1) {
return false;
}
const firstNumber = getOrderedListItemInfo(node.children[0], options).number;
if (firstNumber !== 0) {
return true;
}
return (
node.children.length > 2 &&
getOrderedListItemInfo(node.children[2], options).number === 1
);
}
// The final new line should not include in value
// https://github.com/remarkjs/remark/issues/512
function getFencedCodeBlockValue(node, originalText) {
const { value } = node;
if (
node.position.end.offset === originalText.length &&
value.endsWith("\n") &&
// Code block has no end mark
originalText.endsWith("\n")
) {
return value.slice(0, -1);
}
return value;
}
function mapAst(ast, handler) {
return (function preorder(node, index, parentStack) {
const newNode = { ...handler(node, index, parentStack) };
if (newNode.children) {
newNode.children = newNode.children.map((child, index) =>
preorder(child, index, [newNode, ...parentStack]),
);
}
return newNode;
})(ast, null, []);
}
function isAutolink(node) {
if (node?.type !== "link" || node.children.length !== 1) {
return false;
}
const [child] = node.children;
return locStart(node) === locStart(child) && locEnd(node) === locEnd(child);
}
export {
getFencedCodeBlockValue,
getOrderedListItemInfo,
hasGitDiffFriendlyOrderedList,
INLINE_NODE_TYPES,
INLINE_NODE_WRAPPER_TYPES,
isAutolink,
KIND_CJ_LETTER,
KIND_CJK_PUNCTUATION,
KIND_K_LETTER,
KIND_NON_CJK,
mapAst,
splitText,
};

View File

@@ -0,0 +1,41 @@
const visitorKeys = {
root: ["children"],
paragraph: ["children"],
sentence: ["children"],
word: [],
whitespace: [],
emphasis: ["children"],
strong: ["children"],
delete: ["children"],
inlineCode: [],
wikiLink: [],
link: ["children"],
image: [],
blockquote: ["children"],
heading: ["children"],
code: [],
html: [],
list: ["children"],
thematicBreak: [],
linkReference: ["children"],
imageReference: [],
definition: [],
footnote: ["children"],
footnoteReference: [],
footnoteDefinition: ["children"],
table: ["children"],
tableCell: ["children"],
break: [],
liquidNode: [],
import: [],
export: [],
esComment: [],
jsx: [],
math: [],
inlineMath: [],
tableRow: ["children"],
listItem: ["children"],
text: [],
};
export default visitorKeys;

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');
});
});
});