prettier-plugin-mp
Version:
Prettier plugin for WeChat Mini Program WXML files
657 lines (592 loc) • 24.4 kB
JavaScript
import * as doc from "prettier/doc";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
const { group, hardline, indent, join, line, softline /*, ifBreak*/ } = doc.builders;
const ignoreStartComment = "<!-- prettier-ignore-start -->";
const ignoreEndComment = "<!-- prettier-ignore-end -->";
// Helper: determine if inner lines of a mustache form a simple identifier (single line)
function isSimpleMustacheIdentifier(innerLines) {
if (!Array.isArray(innerLines) || innerLines.length !== 1) return false;
const s = innerLines[0];
return /^[A-Za-z_$][0-9A-Za-z_$]*$/.test(s);
}
// Helper: build doc for multi-line mustache printed text, or return null if not applicable
function buildMultiLineMustacheDocFromPrinted(printedText) {
if (typeof printedText !== "string" || !printedText.includes("\n")) return null;
const rawLines = printedText.split("\n").map((s) => s.trim());
if (rawLines[0] !== "{{" || rawLines[rawLines.length - 1] !== "}}") return null;
const innerLines = rawLines.slice(1, rawLines.length - 1);
const simple = isSimpleMustacheIdentifier(innerLines);
const contentDoc = [];
for (let li = 1; li < rawLines.length - 1; li++) {
contentDoc.push(hardline, rawLines[li]);
}
if (simple) {
// Simple identifier like "title": content aligns with '{{', '}}' at parent indent
return [
indent([
hardline,
"{{",
...contentDoc,
]),
hardline,
"}}",
hardline,
];
}
// Complex expression: indent inner content, align '}}' with '{{'
return [
indent([
hardline,
"{{",
indent(contentDoc),
hardline,
"}}",
]),
hardline,
];
}
function buildIgnoreRanges(ast, comments) {
const ranges = [];
// Use commentTokens from AST if available, otherwise fall back to comments parameter
const commentSource = ast && ast.commentTokens ? ast.commentTokens : comments;
commentSource.sort((left, right) => left.startOffset - right.startOffset);
let start = null;
for (let idx = 0; idx < commentSource.length; idx += 1) {
const comment = commentSource[idx];
if (comment.image === ignoreStartComment) {
start = comment.startOffset || 0;
} else if (start !== null && comment.image === ignoreEndComment) {
const end = comment.endOffset || 0;
ranges.push({ start, end });
start = null;
}
}
return ranges;
}
function parseJsonOption(val) {
if (!val) return undefined;
if (typeof val === 'object') return val;
if (typeof val === 'string') {
try {
return JSON.parse(val);
} catch {
return undefined;
}
}
return undefined;
}
function printAttribute(path, opts, print) {
const node = path.getValue();
const { key, value, rawValue } = node;
// Handle boolean attributes (no value)
if (value === null) {
return key;
}
// Normalize attribute value quoting per wxmlSingleQuote
let attributeValue = rawValue != null ? rawValue : value;
if (typeof attributeValue === "string") {
attributeValue = normalizeAttrValueForWxmlQuotes(attributeValue, opts);
}
return `${key}=${attributeValue}`;
}
function isPlaceholderLikeValue(value) {
if (value == null) return false;
let s = String(value);
// strip surrounding quotes if present
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
s = s.slice(1, -1);
}
// detect long runs of the same symbol characters (e.g., ======, ------, ______, ......, ~~~~~~)
return /([=\-_.~*])\1{5,}/.test(s);
}
function printStartTag(path, opts, print) {
const node = path.getValue();
const parts = ["<", node.name];
if (node.attributes && node.attributes.length > 0) {
const attributeDocs = node.attributes.map((attr) => {
return printAttribute({ getValue: () => attr }, opts, print);
});
// Calculate approximate length to decide line breaks
const attributesLength = attributeDocs.reduce((sum, current) => sum + String(current).length + 1, 0);
const printWidth = (typeof opts.wxmlPrintWidth === 'number') ? opts.wxmlPrintWidth : (opts.printWidth || 80);
// Heuristic: if multiple attributes look like placeholders with long repeated symbols, prefer breaking
const placeholderCount = (node.attributes || []).reduce((acc, attr) => {
const raw = attr.rawValue != null ? attr.rawValue : attr.value;
return acc + (isPlaceholderLikeValue(raw) ? 1 : 0);
}, 0);
const placeholderPenalty = placeholderCount > 0 ? placeholderCount * 10 : 0;
const approximateLength = node.name.length + 1 + attributesLength + placeholderPenalty; // "<" + name + space + attrs
const shouldBreak =
approximateLength > printWidth ||
placeholderCount >= 2 ||
(node.selfClosing && node.attributes.length >= 4);
if (shouldBreak) {
// Break attributes to multiple lines
const indentedAttributes = indent([
softline,
join(hardline, attributeDocs)
]);
parts.push(indentedAttributes, hardline);
} else {
// Keep on same line
parts.push(" ", join(" ", attributeDocs));
}
}
if (node.selfClosing) {
parts.push(" />");
} else {
parts.push(">");
}
return parts;
}
function printEndTag(path, opts, print) {
const node = path.getValue();
return `</${node.name}>`;
}
// Merge default Babel parser options with user-provided ones from Prettier rc
function getBabelParserOptions(opts) {
const userRaw = (opts && opts.wxsBabelParserOptions) ? opts.wxsBabelParserOptions : undefined;
const user = parseJsonOption(userRaw) || {};
return {
sourceType: 'script',
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true,
allowSuperOutsideMethod: true,
plugins: [
// 常见现代语法,尽量容忍更多写法
'jsx',
'classProperties',
'optionalChaining',
'nullishCoalescingOperator',
'dynamicImport',
'numericSeparator',
'topLevelAwait',
'logicalAssignment',
'objectRestSpread'
],
// allow users to specify additional parser plugins, etc.
...user
};
}
// Merge default Babel generator options with user-provided ones from Prettier rc
function getBabelGeneratorOptions(opts, useSingleQuote) {
const userRaw = (opts && opts.wxsBabelGeneratorOptions) ? opts.wxsBabelGeneratorOptions : undefined;
const user = parseJsonOption(userRaw) || {};
return {
comments: true,
compact: false,
retainLines: false,
quotes: useSingleQuote ? 'single' : 'double',
jsescOption: { quotes: useSingleQuote ? 'single' : 'double' },
semicolons: opts.wxsSemi !== false,
...user
};
}
// Quick syntax check via Babel to avoid Prettier throwing parser errors
function canParseWithBabel(jsCode, opts) {
try {
parse(jsCode, getBabelParserOptions(opts));
return true;
} catch {
return false;
}
}
// Use Prettier to format JS inside <wxs>
function formatWxsByPrettier(jsCode, opts) {
// 不再尝试内嵌调用 Prettier(v3 的 format 为 Promise,打印器无法等待),统一走 Babel 生成路径
return null;
}
// Fallback: Use Babel generator to produce stable output close to Prettier
function formatWxsByBabelCompat(jsCode, opts) {
try {
const ast = parse(jsCode, getBabelParserOptions(opts));
const useSingle = opts.wxsSingleQuote !== false; // default true
const gen = (generate && (generate.default || generate));
if (typeof gen !== 'function') {
throw new TypeError('generate is not a function');
}
const { code } = gen(
ast,
getBabelGeneratorOptions(opts, useSingle),
jsCode
);
let pretty = code.replace(/\bfunction\(/g, 'function (');
return pretty.trimEnd();
} catch (e) {
try { console.error('[wxs][babel] parse/generate error:', e && e.message); } catch {}
return null;
}
}
function indentLines(text, indentSize) {
const pad = " ".repeat(indentSize);
return text
.split("\n")
.map((l) => (l.trim() ? pad + l : l))
.join("\n");
}
function formatInlineJsExpression(expr, opts) {
if (typeof expr !== 'string') return expr;
// 1) Collapse any newlines (and surrounding spaces) into a single space
let s = expr.replace(/[ \t]*[\r\n]+[ \t]*/g, ' ');
// 2) Normalize spaces around logical operators without touching others
s = s.replace(/\s*&&\s*/g, ' && ').replace(/\s*\|\|\s*/g, ' || ');
return s.trim();
}
function formatWxmlInterpolations(text, opts) {
if (typeof text !== 'string' || text.indexOf('{{') === -1) return text;
return text.replace(/{{(\s*)([\s\S]*?)(\s*)}}/g, (m, lws, expr, rws) => {
const formatted = formatInlineJsExpression(expr, opts);
return `{{${lws}${formatted}${rws}}}`;
});
}
function normalizeAttrValueForWxmlQuotes(value, opts) {
if (value == null) return null;
let attributeValue = String(value);
// Apply inline expression formatting inside quotes or raw
const isQuoted = (attributeValue.startsWith('"') && attributeValue.endsWith('"')) || (attributeValue.startsWith("'") && attributeValue.endsWith("'"));
if (isQuoted) {
const quote = attributeValue[0];
let content = attributeValue.slice(1, -1);
content = formatWxmlInterpolations(content, opts);
// Decide final quote style based on preference and content
const preferSingle = !!opts.wxmlSingleQuote;
if (preferSingle && !content.includes("'")) {
attributeValue = `'${content}'`;
} else if (!preferSingle && !content.includes('"')) {
attributeValue = `"${content}"`;
} else {
attributeValue = `${quote}${content}${quote}`;
}
} else {
// Not quoted; first format interpolations then add quotes
const content = formatWxmlInterpolations(attributeValue, opts);
attributeValue = opts.wxmlSingleQuote ? `'${content}'` : `"${content}"`;
}
return attributeValue;
}
function enforceWxsStringQuotes(code, useSingleQuote) {
if (typeof code !== 'string') return code;
if (useSingleQuote) {
// Convert simple double-quoted strings (no quotes or backslashes inside) to single-quoted
return code.replace(/\"([^\"'\\\n\r]*)\"/g, "'$1'");
} else {
// Convert simple single-quoted strings (no quotes or backslashes inside) to double-quoted
return code.replace(/'([^\"'\\\n\r]*)'/g, '"$1"');
}
}
function printMisc(path, opts, print) {
const node = path.getValue();
// Handle WXScript nodes
if (node.type === "WXScript") {
let result = "";
// Print start tag manually
if (node.startTag) {
const isSelfClosing = !!node.startTag.selfClosing;
result += `<${node.startTag.name}`;
if (node.startTag.attributes && node.startTag.attributes.length > 0) {
for (const attr of node.startTag.attributes) {
const normalized = attr.value === null
? attr.key
: `${attr.key}=${normalizeAttrValueForWxmlQuotes(attr.value, opts)}`;
result += ` ${normalized}`;
}
}
if (isSelfClosing) {
result += " />";
return result; // self-closing: no content, no end tag
} else {
result += ">";
}
}
// Print content with proper JavaScript formatting
if (node.value) {
result += "\n";
const jsCode = node.value.trim();
const indentSize = typeof opts.wxsTabWidth === 'number' ? opts.wxsTabWidth : (opts.tabWidth || 2);
let formatted = null; // 不再使用 Prettier 路径
if (formatted == null) {
formatted = formatWxsByBabelCompat(jsCode, opts);
}
if (typeof formatted === 'string') {
// Enforce preferred string quote style for simple literals only when formatted
const useSingle = opts.wxsSingleQuote !== false;
formatted = enforceWxsStringQuotes(formatted, useSingle);
} else {
try {
const snippet = jsCode.split('\n').slice(0, 5).join('\n');
console.error('[wxs] Unable to format. First lines:', snippet);
} catch {}
throw new Error("Failed to parse/format <wxs> JavaScript");
}
const content = (formatted.endsWith("\n") ? formatted : formatted + "\n");
result += indentLines(content, indentSize);
}
// Print end tag manually
if (node.endTag) {
result += `</${node.endTag.name}>`;
}
return result;
}
throw new Error(`printMisc received unknown node type: ${node.type}. This is a bug in the printer.`);
}
function printCharData(path, opts, print) {
const node = path.getValue();
const { value } = node;
if (value == null) return "";
if (value.trim() === "") {
// Return whitespace as-is; element-level logic decides whether to keep it
return value;
}
// Normalize inline template expressions
const normalized = formatWxmlInterpolations(value, opts);
// Do not trim() here to avoid silently removing significant leading/trailing spaces in text nodes
return normalized;
}
function printElement(path, opts, print) {
const node = path.getValue();
const parts = [];
if (node.startTag) {
parts.push(path.call(print, "startTag"));
}
if (node.children && node.children.length > 0) {
// Decide whether to inline children based on their raw content
const child0 = node.children[0];
const isTextNodeType = (n) => n && (n.type === "WXText" || n.type === "WXCharData");
const isTextLikeNode = (n) => isTextNodeType(n) || n.type === "WXInterpolation";
const getNodeString = (n) => {
if (isTextNodeType(n)) return typeof n.value === 'string' ? n.value : '';
if (n.type === 'WXInterpolation') return typeof n.rawValue === 'string' ? n.rawValue : '';
return '';
};
const getNodeLen = (n) => getNodeString(n).length;
const trimmedLen0 = isTextNodeType(child0) && typeof child0.value === 'string' ? child0.value.trim().length : 0;
const singleTextInline = node.children.length === 1 &&
isTextNodeType(child0) &&
trimmedLen0 > 0 &&
trimmedLen0 < 50 &&
!child0.value.includes("\n");
// Determine if children are purely textual/interpolation
const onlyTextualChildren = node.children.every((n) => isTextLikeNode(n));
const hasNewline = node.children.some((n) => getNodeString(n).includes("\n"));
const totalLen = node.children.reduce((acc, n) => acc + getNodeLen(n), 0);
const hasMeaningful = node.children.some((n) => (isTextNodeType(n) && typeof n.value === 'string' && n.value.trim() !== '') || n.type === 'WXInterpolation');
const smallInlineMix = node.children.length <= 3 && onlyTextualChildren && !hasNewline && totalLen < 50 && hasMeaningful;
const tagName = (node.startTag && node.startTag.name) || (node.endTag && node.endTag.name) || "";
const lowerName = typeof tagName === "string" ? tagName.toLowerCase() : "";
// Only true block-level tag that must never inline
const isAlwaysBlock = lowerName === "block";
// Strict text container only when single pure text child under <text>
const hasSingleTextChild = node.children.length === 1 && isTextNodeType(child0);
const isStrictTextContainer = (lowerName === "text") && hasSingleTextChild;
// Build prefer-break tags set from options
const preferBreakTagsInput = (typeof opts.wxmlPreferBreakTags === 'string') ? opts.wxmlPreferBreakTags : '';
const preferBreakTags = new Set(preferBreakTagsInput.split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
const isPreferBlock = preferBreakTags.has(lowerName);
// Attributes presence (for simple <text> rule)
const attrsArr = (node.startTag && Array.isArray(node.startTag.attributes)) ? node.startTag.attributes : [];
const hasAnyAttrs = attrsArr.length > 0;
// Simplified: treat <block> as always-block, selected tags as prefer-break.
let shouldInline = (singleTextInline || smallInlineMix) && !isAlwaysBlock && !isPreferBlock;
// Only <text> strictly checks: if it has any attributes, force break (no inline)
if (lowerName === 'text' && hasAnyAttrs) {
shouldInline = false;
}
// For <text>, baseline: do not inline unless content is short/simple and structure is trivial
if (lowerName === 'text') {
shouldInline = false;
if (!hasAnyAttrs && onlyTextualChildren && !hasNewline && node.children.length === 1) {
const only = node.children[0];
const rawCombined = getNodeString(only);
const trimmed = typeof rawCombined === 'string' ? rawCombined.trim() : '';
const isSingleMustache = trimmed.startsWith('{{') && trimmed.endsWith('}}');
const containsMustache = typeof rawCombined === 'string' && rawCombined.includes('{{');
if (containsMustache && isSingleMustache) {
const inner = trimmed.slice(2, -2);
// Complexity heuristics: break if contains object/array literal, or has multiple &&, or both && and ||
const hasObjectLiteral = /\{[^}]*:/.test(inner);
const hasArrayLiteral = /\[[^\]]*,[^\]]*\]/.test(inner) || /^\s*\[/.test(inner.trim());
const andCount = (inner.match(/&&/g) || []).length;
const hasOr = inner.includes('||');
const complex = hasObjectLiteral || hasArrayLiteral || andCount >= 2 || (andCount >= 1 && hasOr);
shouldInline = !complex;
} else if (isTextNodeType(only)) {
// single pure text: inline if reasonably short
shouldInline = trimmed.length <= 50;
}
}
}
// Strict text rendering switch from options
const strictTextMode = !!opts.wxmlStrictText;
const treatAsStrictText = strictTextMode && (lowerName === 'text' || isStrictTextContainer);
// (no forced non-inline here; small text in <text> can still inline)
// Now collect printed children according to the decision
const childrenParts = [];
const childEntries = [];
for (let i = 0; i < node.children.length; i++) {
const childNode = node.children[i];
if (!shouldInline) {
if (!treatAsStrictText && (childNode.type === "WXText" || childNode.type === "WXCharData") && typeof childNode.value === 'string' && childNode.value.trim() === '') {
continue; // drop whitespace-only nodes when not inlining (non-strict-text containers only)
}
}
const printed = path.call(print, "children", i);
if (printed && printed !== "") {
childrenParts.push(printed);
childEntries.push({ index: i, node: childNode, printed });
}
}
if (childrenParts.length > 0) {
if (shouldInline) {
parts.push(...childrenParts);
} else {
const childrenWithBreaks = [];
for (let i = 0; i < childrenParts.length; i++) {
if (i > 0) childrenWithBreaks.push(hardline);
const entry = childEntries[i];
const printed = childrenParts[i];
// In non-strict mode, split multi-line text nodes into doc parts separated by hardline
if (!treatAsStrictText && entry && (entry.node.type === "WXText" || entry.node.type === "WXCharData") && typeof printed === "string" && printed.includes("\n")) {
const lines = printed.split("\n");
for (let li = 0; li < lines.length; li++) {
if (li > 0) childrenWithBreaks.push(hardline);
if (lines[li] !== "") {
childrenWithBreaks.push(lines[li]);
}
}
} else {
childrenWithBreaks.push(printed);
}
}
if (treatAsStrictText) {
// Strict mode: preserve children exactly as parsed.
// Additionally: convert whitespace-only children without newlines into hardline to reflect visual blank lines.
const combinedRaw = node.children.map((n) => getNodeString(n)).join("");
const startsWithNL = typeof combinedRaw === 'string' && combinedRaw.startsWith("\n");
const endsWithNL = typeof combinedRaw === 'string' && combinedRaw.endsWith("\n");
const isWhitespaceOnlyNoNL = (entry) => {
if (!entry) return false;
const n = entry.node;
if ((n.type === "WXText" || n.type === "WXCharData") && typeof n.value === 'string') {
return n.value.trim() === '' && !n.value.includes("\n");
}
return false;
};
if (!startsWithNL) {
parts.push(hardline);
}
for (let i = 0; i < childrenParts.length; i++) {
parts.push(isWhitespaceOnlyNoNL(childEntries[i]) ? hardline : childrenParts[i]);
}
if (!endsWithNL) {
parts.push(hardline);
}
} else {
// Special-case: multi-line mustache block like "{{\n title\n}}" inside non-<text> tag
let didSpecial = false;
if (lowerName !== 'text' && childrenParts.length === 1 && childEntries.length === 1) {
const entry0 = childEntries[0];
const printed0 = childrenParts[0];
const raw0 = getNodeString(entry0.node);
const isTextNode = entry0 && (entry0.node.type === "WXText" || entry0.node.type === "WXCharData");
if (isTextNode && typeof printed0 === 'string') {
const mlDoc = buildMultiLineMustacheDocFromPrinted(printed0);
if (mlDoc) {
parts.push(...mlDoc);
didSpecial = true;
}
}
}
if (!didSpecial) {
parts.push(indent([hardline, ...childrenWithBreaks]), hardline);
}
}
}
}
}
if (node.endTag) {
parts.push(path.call(print, "endTag"));
}
return group(parts);
}
function printDocument(path, opts, print) {
const node = path.getValue();
const { body } = node;
if (!body || body.length === 0) return "";
const parts = [];
let lastWasBlock = false; // blocks: element/script/comment
body.forEach((child, index) => {
// Drop whitespace-only top-level text nodes to avoid spurious blank lines
if ((child.type === 'WXText' || child.type === 'WXCharData') && typeof child.value === 'string' && child.value.trim() === '') {
return;
}
const printed = path.call(print, "body", index);
const isBlock = child.type === 'WXElement' || child.type === 'WXScript' || child.type === 'WXComment';
if (printed && printed !== "") {
if (parts.length > 0 && (isBlock || lastWasBlock)) {
parts.push(hardline);
}
parts.push(printed);
}
if (printed && printed !== "") {
lastWasBlock = isBlock;
}
});
if (parts.length > 0) {
parts.push(hardline);
}
return group(parts);
}
function printComment(path, opts, print) {
const node = path.getValue();
return `<!--${node.value}-->`;
}
const printer = {
preprocess(ast, options) {
if (ast.commentTokens && ast.commentTokens.length > 0) {
ast.ignoreRanges = buildIgnoreRanges(ast, ast.commentTokens);
}
return ast;
},
print(path, opts, print) {
const node = path.getValue();
const ast = path.stack && path.stack[0];
if (ast && ast.ignoreRanges && node.location) {
const nodeStart = node.location.startOffset;
const nodeEnd = node.location.endOffset;
for (const range of ast.ignoreRanges) {
if (nodeStart >= range.start && nodeEnd <= range.end) {
return opts.originalText.slice(nodeStart, nodeEnd);
}
}
}
switch (node.type) {
case "WXAttribute":
return printAttribute(path, opts, print);
case "WXCharData":
return printCharData(path, opts, print);
case "Program":
return printDocument(path, opts, print);
case "WXElement":
return printElement(path, opts, print);
case "WXComment":
return printComment(path, opts, print);
case "WXScript":
return printMisc(path, opts, print);
case "WXStartTag":
return printStartTag(path, opts, print);
case "WXEndTag":
return printEndTag(path, opts, print);
case "WXText":
return printCharData(path, opts, print);
case "WXInterpolation":
if (!node.rawValue) {
throw new Error(`WXInterpolation node missing rawValue. This is a bug in the parser or printer.`);
}
return node.rawValue;
default:
throw new Error(`Unknown node type: ${node.type}. This is a bug in the printer.`);
}
}
};
export default printer;