release: WRNexusJS 0.8.0
This commit is contained in:
@@ -0,0 +1,790 @@
|
||||
// The formatter intentionally operates on partially written source. Its small
|
||||
// scanner values are dynamically shaped, while the public API below remains typed.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-nocheck
|
||||
|
||||
export interface FormatWrnOptions {
|
||||
insertSpaces?: boolean;
|
||||
tabSize?: number;
|
||||
printWidth?: number;
|
||||
multilineAttributes?: boolean;
|
||||
}
|
||||
|
||||
const VOID_ELEMENTS = new Set([
|
||||
"area",
|
||||
"base",
|
||||
"br",
|
||||
"col",
|
||||
"embed",
|
||||
"hr",
|
||||
"img",
|
||||
"input",
|
||||
"link",
|
||||
"meta",
|
||||
"param",
|
||||
"source",
|
||||
"track",
|
||||
"wbr",
|
||||
]);
|
||||
|
||||
function splitPropDeclarations(value) {
|
||||
const declarations = [];
|
||||
let start = 0;
|
||||
let index = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
let square = 0;
|
||||
let brace = 0;
|
||||
let paren = 0;
|
||||
let segmentHasColon = false;
|
||||
let segmentHasEquals = false;
|
||||
|
||||
const isIdentifierStart = (character) => /[A-Za-z_]/.test(character || "");
|
||||
const isIdentifierPart = (character) => /[A-Za-z0-9_]/.test(character || "");
|
||||
|
||||
const beginsDeclaration = (position) => {
|
||||
let cursor = position;
|
||||
while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1;
|
||||
if (value.slice(cursor).startsWith("@event")) {
|
||||
cursor += "@event".length;
|
||||
if (!/\s/.test(value[cursor] || "")) return false;
|
||||
while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1;
|
||||
if (!isIdentifierStart(value[cursor])) return false;
|
||||
cursor += 1;
|
||||
while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1;
|
||||
while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1;
|
||||
return value[cursor] === "=" ? "=" : null;
|
||||
}
|
||||
if (!isIdentifierStart(value[cursor])) return false;
|
||||
cursor += 1;
|
||||
while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1;
|
||||
if (value[cursor] === "?") cursor += 1;
|
||||
while (cursor < value.length && /[ \t]/.test(value[cursor])) cursor += 1;
|
||||
return value[cursor] === "=" || value[cursor] === ":" ? value[cursor] : null;
|
||||
};
|
||||
|
||||
while (index < value.length) {
|
||||
const character = value[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === quote) quote = null;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
quote = character;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "[") square += 1;
|
||||
else if (character === "]" && square > 0) square -= 1;
|
||||
else if (character === "{") brace += 1;
|
||||
else if (character === "}" && brace > 0) brace -= 1;
|
||||
else if (character === "(") paren += 1;
|
||||
else if (character === ")" && paren > 0) paren -= 1;
|
||||
|
||||
const topLevel = square === 0 && brace === 0 && paren === 0;
|
||||
if (topLevel && character === ":") segmentHasColon = true;
|
||||
if (topLevel && character === "=") segmentHasEquals = true;
|
||||
|
||||
const candidateDelimiter = topLevel && /\s/.test(character) ? beginsDeclaration(index) : null;
|
||||
const beginsNext =
|
||||
candidateDelimiter === ":" ||
|
||||
(candidateDelimiter === "=" && (segmentHasEquals || !segmentHasColon));
|
||||
|
||||
if (
|
||||
topLevel &&
|
||||
/\s/.test(character) &&
|
||||
value.slice(start, index).trim() !== "@event" &&
|
||||
beginsNext
|
||||
) {
|
||||
const declaration = value.slice(start, index).trim();
|
||||
if (declaration) declarations.push(declaration);
|
||||
while (index < value.length && /\s/.test(value[index])) index += 1;
|
||||
start = index;
|
||||
segmentHasColon = false;
|
||||
segmentHasEquals = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
const declaration = value.slice(start).trim();
|
||||
if (declaration) declarations.push(declaration);
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function splitOutputDeclarations(value) {
|
||||
const declarations = [];
|
||||
let start = 0;
|
||||
let paren = 0;
|
||||
let angle = 0;
|
||||
let square = 0;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
const startsOutput = (position) => {
|
||||
let cursor = position;
|
||||
while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1;
|
||||
if (!/[A-Za-z_$]/.test(value[cursor] || "")) return false;
|
||||
cursor += 1;
|
||||
while (cursor < value.length && /[A-Za-z0-9_$]/.test(value[cursor] || "")) cursor += 1;
|
||||
while (cursor < value.length && /\s/.test(value[cursor])) cursor += 1;
|
||||
return value[cursor] === "(";
|
||||
};
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
if (quote !== null) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === quote) quote = null;
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
if (character === "(") paren += 1;
|
||||
else if (character === ")" && paren > 0) paren -= 1;
|
||||
else if (character === "[") square += 1;
|
||||
else if (character === "]" && square > 0) square -= 1;
|
||||
else if (character === "<") angle += 1;
|
||||
else if (character === ">" && angle > 0) angle -= 1;
|
||||
if (paren === 0 && square === 0 && angle === 0 && /\s/.test(character) && startsOutput(index)) {
|
||||
const declaration = value.slice(start, index).trim();
|
||||
if (declaration) declarations.push(declaration);
|
||||
while (index < value.length && /\s/.test(value[index])) index += 1;
|
||||
start = index;
|
||||
index -= 1;
|
||||
}
|
||||
}
|
||||
const finalDeclaration = value.slice(start).trim();
|
||||
if (finalDeclaration) declarations.push(finalDeclaration);
|
||||
return declarations;
|
||||
}
|
||||
|
||||
function formatInlineDeclarationBlock(value, unit, depth) {
|
||||
const match = /^(props|state|computed|outputs)\s*\{([\s\S]*)\}$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
const declarations =
|
||||
match[1] === "outputs"
|
||||
? splitOutputDeclarations(match[2].trim())
|
||||
: splitPropDeclarations(match[2].trim());
|
||||
return [
|
||||
`${unit.repeat(depth)}${match[1]} {`,
|
||||
...declarations.map((declaration) => `${unit.repeat(depth + 1)}${declaration}`),
|
||||
`${unit.repeat(depth)}}`,
|
||||
];
|
||||
}
|
||||
|
||||
function formatInlinePropsBlock(value, unit, depth) {
|
||||
const match = /^props\s*\{([\s\S]*)\}$/.exec(value.trim());
|
||||
if (!match) return null;
|
||||
|
||||
const declarations = splitPropDeclarations(match[1].trim());
|
||||
if (declarations.length === 0) {
|
||||
return [`${unit.repeat(depth)}props {`, `${unit.repeat(depth)}}`];
|
||||
}
|
||||
|
||||
return [
|
||||
`${unit.repeat(depth)}props {`,
|
||||
...declarations.map((declaration) => `${unit.repeat(depth + 1)}${declaration}`),
|
||||
`${unit.repeat(depth)}}`,
|
||||
];
|
||||
}
|
||||
|
||||
function findOpeningTagEnd(value) {
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const character = value[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ">") {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
function parseAttributes(value) {
|
||||
const attributes = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < value.length) {
|
||||
while (index < value.length && /\s/.test(value[index])) index += 1;
|
||||
if (index >= value.length) break;
|
||||
|
||||
const start = index;
|
||||
while (index < value.length && !/[\s=]/.test(value[index])) index += 1;
|
||||
while (index < value.length && /\s/.test(value[index])) index += 1;
|
||||
|
||||
if (value[index] === "=") {
|
||||
index += 1;
|
||||
while (index < value.length && /\s/.test(value[index])) index += 1;
|
||||
|
||||
const quote = value[index];
|
||||
if (quote === '"' || quote === "'") {
|
||||
index += 1;
|
||||
let escaped = false;
|
||||
while (index < value.length) {
|
||||
const character = value[index++];
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === quote) break;
|
||||
}
|
||||
} else if (value[index] === "{") {
|
||||
let depth = 0;
|
||||
let expressionQuote = null;
|
||||
let escaped = false;
|
||||
while (index < value.length) {
|
||||
const character = value[index++];
|
||||
if (expressionQuote !== null) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === expressionQuote) expressionQuote = null;
|
||||
continue;
|
||||
}
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
expressionQuote = character;
|
||||
} else if (character === "{") {
|
||||
depth += 1;
|
||||
} else if (character === "}" && --depth === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
while (index < value.length && !/\s/.test(value[index])) index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const attribute = value.slice(start, index).trim();
|
||||
if (attribute) attributes.push(attribute);
|
||||
}
|
||||
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function parseStructuredAttribute(attribute) {
|
||||
const match = /^([^\s=]+)\s*=\s*\{([\s\S]*)\}$/.exec(attribute);
|
||||
if (!match) return null;
|
||||
|
||||
const expression = match[2].trim();
|
||||
if (!expression.startsWith("[") && !expression.startsWith("{")) return null;
|
||||
|
||||
try {
|
||||
return {
|
||||
name: match[1],
|
||||
value: JSON.parse(expression),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatAttribute(attribute, indentation, unit) {
|
||||
const structured = parseStructuredAttribute(attribute);
|
||||
if (!structured) return [`${indentation}${attribute}`];
|
||||
|
||||
const jsonLines = JSON.stringify(structured.value, null, unit).split("\n");
|
||||
if (jsonLines.length === 1) {
|
||||
return [`${indentation}${structured.name}={${jsonLines[0]}}`];
|
||||
}
|
||||
|
||||
return [
|
||||
`${indentation}${structured.name}={${jsonLines[0]}`,
|
||||
...jsonLines.slice(1, -1).map((line) => `${indentation}${line}`),
|
||||
`${indentation}${jsonLines.at(-1)}}`,
|
||||
];
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function parseOpeningTag(value) {
|
||||
const endIndex = findOpeningTagEnd(value);
|
||||
|
||||
if (endIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openingPart = value.slice(0, endIndex + 1);
|
||||
const remainder = value.slice(endIndex + 1).trim();
|
||||
|
||||
const match = /^<([A-Za-z][\w$:.-]*)([\s\S]*?)(\/?)>$/.exec(openingPart);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const tagName = match[1];
|
||||
const attributes = parseAttributes(match[2].trim());
|
||||
const selfClosing = match[3] === "/";
|
||||
|
||||
const escapedTagName = escapeRegExp(tagName);
|
||||
|
||||
const immediateClosing = new RegExp(`^<\\/${escapedTagName}\\s*>`, "i").test(remainder);
|
||||
|
||||
const trailingClosingMatch = new RegExp(`^([\\s\\S]*?)<\\/${escapedTagName}\\s*>$`, "i").exec(
|
||||
remainder,
|
||||
);
|
||||
|
||||
const trailingClosing = trailingClosingMatch !== null;
|
||||
|
||||
const inlineContent = trailingClosing ? trailingClosingMatch[1].trim() : "";
|
||||
|
||||
const inlineClosing = trailingClosing && inlineContent.length === 0;
|
||||
|
||||
const closesInRemainder = immediateClosing || trailingClosing;
|
||||
|
||||
return {
|
||||
tagName,
|
||||
attributes,
|
||||
selfClosing,
|
||||
inlineClosing,
|
||||
immediateClosing,
|
||||
trailingClosing,
|
||||
inlineContent,
|
||||
closesInRemainder,
|
||||
remainder,
|
||||
};
|
||||
}
|
||||
|
||||
function formatOpeningTag(value, unit, depth, printWidth = 100, multilineAttributes = true) {
|
||||
const parsed = parseOpeningTag(value);
|
||||
|
||||
if (!parsed) {
|
||||
return {
|
||||
lines: [`${unit.repeat(depth)}${value.trim()}`],
|
||||
opensElement: false,
|
||||
};
|
||||
}
|
||||
|
||||
const baseIndent = unit.repeat(depth);
|
||||
const childIndent = unit.repeat(depth + 1);
|
||||
|
||||
const normalizedOpening =
|
||||
`<${parsed.tagName}` +
|
||||
`${parsed.attributes.length ? ` ${parsed.attributes.join(" ")}` : ""}` +
|
||||
`${parsed.selfClosing ? " /" : ""}>`;
|
||||
|
||||
const normalizedSingleLine = `${normalizedOpening}${parsed.remainder}`;
|
||||
|
||||
const shouldBreak =
|
||||
value.includes("\n") ||
|
||||
(multilineAttributes && parsed.attributes.length > 0) ||
|
||||
baseIndent.length + normalizedSingleLine.length > printWidth;
|
||||
|
||||
const opensElement =
|
||||
!parsed.selfClosing &&
|
||||
!parsed.closesInRemainder &&
|
||||
!VOID_ELEMENTS.has(parsed.tagName.toLowerCase());
|
||||
|
||||
if (!shouldBreak) {
|
||||
return {
|
||||
lines: [`${baseIndent}${normalizedSingleLine}`],
|
||||
opensElement,
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`${baseIndent}<${parsed.tagName}`,
|
||||
...parsed.attributes.flatMap((attribute) => formatAttribute(attribute, childIndent, unit)),
|
||||
];
|
||||
|
||||
if (parsed.selfClosing) {
|
||||
lines.push(`${baseIndent}/>`);
|
||||
return {
|
||||
lines,
|
||||
opensElement,
|
||||
};
|
||||
}
|
||||
|
||||
lines.push(`${baseIndent}>`);
|
||||
|
||||
if (parsed.trailingClosing) {
|
||||
if (parsed.inlineContent) {
|
||||
lines.push(`${childIndent}${parsed.inlineContent}`);
|
||||
}
|
||||
|
||||
lines.push(`${baseIndent}</${parsed.tagName}>`);
|
||||
} else if (parsed.remainder) {
|
||||
lines.push(`${parsed.immediateClosing ? baseIndent : childIndent}${parsed.remainder}`);
|
||||
}
|
||||
|
||||
return {
|
||||
lines,
|
||||
opensElement,
|
||||
};
|
||||
}
|
||||
|
||||
function isMultilineOpeningTagStart(value) {
|
||||
if (!value.startsWith("<")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
value.startsWith("</") ||
|
||||
value.startsWith("<!--") ||
|
||||
value.startsWith("<!") ||
|
||||
value.startsWith("<?")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return findOpeningTagEnd(value) === -1;
|
||||
}
|
||||
|
||||
function isClosingTag(value) {
|
||||
return /^<\/[A-Za-z][\w$:.-]*\s*>/.test(value);
|
||||
}
|
||||
|
||||
function isControlBlockOpen(value) {
|
||||
return /^\{#(?:if|each)\b[\s\S]*\}$/.test(value);
|
||||
}
|
||||
|
||||
function isControlBlockMiddle(value) {
|
||||
return /^\{:(?:else(?:\s+if\b[\s\S]*)?|empty)\}$/.test(value);
|
||||
}
|
||||
|
||||
function isControlBlockClose(value) {
|
||||
return /^\{\/(?:if|each)\}$/.test(value);
|
||||
}
|
||||
|
||||
function countLeadingClosingBraces(value) {
|
||||
let index = 0;
|
||||
let count = 0;
|
||||
|
||||
while (index < value.length) {
|
||||
while (index < value.length && /\s/.test(value[index])) {
|
||||
index += 1;
|
||||
}
|
||||
|
||||
if (value[index] !== "}" && value[index] !== "]") {
|
||||
break;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count braces outside strings and HTML comments.
|
||||
*
|
||||
* This supports WRN blocks, function bodies, lifecycle hooks,
|
||||
* watcher bodies and multiline JavaScript object literals.
|
||||
*/
|
||||
function countStructuralBraces(value) {
|
||||
let openings = 0;
|
||||
let closings = 0;
|
||||
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
let htmlComment = false;
|
||||
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!quote && !htmlComment && value.startsWith("<!--", index)) {
|
||||
htmlComment = true;
|
||||
index += 3;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (htmlComment && value.startsWith("-->", index)) {
|
||||
htmlComment = false;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (htmlComment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const character = value[index];
|
||||
|
||||
if (quote !== null) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === quote) {
|
||||
quote = null;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"' || character === "'" || character === "`") {
|
||||
quote = character;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "{") {
|
||||
openings += 1;
|
||||
} else if (character === "}") {
|
||||
closings += 1;
|
||||
} else if (character === "[") {
|
||||
openings += 1;
|
||||
} else if (character === "]") {
|
||||
closings += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openings,
|
||||
closings,
|
||||
};
|
||||
}
|
||||
|
||||
function collectOpeningTag(inputLines, startIndex) {
|
||||
const collected = [inputLines[startIndex].trim()];
|
||||
|
||||
let index = startIndex;
|
||||
|
||||
while (index + 1 < inputLines.length) {
|
||||
const joined = collected.join(" ");
|
||||
|
||||
if (findOpeningTagEnd(joined) !== -1) {
|
||||
break;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
collected.push(inputLines[index].trim());
|
||||
}
|
||||
|
||||
return {
|
||||
// Preserve the fact that the opening tag was already multiline so a
|
||||
// second formatter pass cannot collapse it back to one line.
|
||||
value: collected.join("\n"),
|
||||
endIndex: index,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Put WRN template control markers on their own lines before indentation.
|
||||
*
|
||||
* Authors commonly write compact fragments such as
|
||||
* `{#if loading}<span>…</span>{/if}`. Treating that as one line prevents the
|
||||
* normal HTML and control-block formatters from seeing its structure.
|
||||
*/
|
||||
function expandInlineControlBlocks(lines) {
|
||||
const marker =
|
||||
/(\{#(?:if|each)\b[^}]*\}|\{:(?:else(?:\s+if\b[^}]*)?|empty)\}|\{\/(?:if|each)\})/g;
|
||||
|
||||
return lines.flatMap((line) => {
|
||||
if (!marker.test(line)) return [line];
|
||||
marker.lastIndex = 0;
|
||||
|
||||
const indentation = line.match(/^\s*/)?.[0] ?? "";
|
||||
const segments = line
|
||||
.split(marker)
|
||||
.map((segment) => segment.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return segments.map((segment) => `${indentation}${segment}`);
|
||||
});
|
||||
}
|
||||
|
||||
function expandStructuredStateDeclarations(lines, unit) {
|
||||
return lines.flatMap((line) => {
|
||||
const match = /^(\s*state\s+[A-Za-z_$][\w$]*\s*=\s*)([\\[{][\s\S]*)$/.exec(line);
|
||||
if (!match) return [line];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(match[2].trim());
|
||||
const jsonLines = JSON.stringify(parsed, null, unit).split("\n");
|
||||
if (jsonLines.length === 1) return [`${match[1]}${jsonLines[0]}`];
|
||||
|
||||
const leading = match[1].match(/^\s*/)?.[0] ?? "";
|
||||
return [
|
||||
`${match[1]}${jsonLines[0]}`,
|
||||
...jsonLines.slice(1).map((jsonLine) => `${leading}${jsonLine}`),
|
||||
];
|
||||
} catch {
|
||||
return [line];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function formatWrnPass(source: string, options: FormatWrnOptions = {}): string {
|
||||
const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize ?? 4);
|
||||
|
||||
const printWidth = options.printWidth ?? 100;
|
||||
|
||||
const multilineAttributes = options.multilineAttributes !== false;
|
||||
|
||||
let codeDepth = 0;
|
||||
let htmlDepth = 0;
|
||||
let controlDepth = 0;
|
||||
let index = 0;
|
||||
|
||||
const sourceLines = source.replace(/\r\n/g, "\n").split("\n");
|
||||
const inputLines = expandInlineControlBlocks(
|
||||
expandStructuredStateDeclarations(sourceLines, unit),
|
||||
);
|
||||
|
||||
const output = [];
|
||||
|
||||
let previousWasBlank = false;
|
||||
|
||||
while (index < inputLines.length) {
|
||||
const originalLine = inputLines[index];
|
||||
|
||||
let value = originalLine.trim();
|
||||
|
||||
if (value === "") {
|
||||
if (!previousWasBlank && output.length > 0) {
|
||||
output.push("");
|
||||
}
|
||||
|
||||
previousWasBlank = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
previousWasBlank = false;
|
||||
|
||||
if (/^import\b/.test(value)) {
|
||||
const importLines = [value];
|
||||
while (
|
||||
!/(?:\bfrom\s+)?["'][^"']+["']\s*;?$/.test(importLines[importLines.length - 1]) &&
|
||||
index + 1 < inputLines.length
|
||||
) {
|
||||
index += 1;
|
||||
importLines.push(inputLines[index].trim());
|
||||
}
|
||||
output.push(importLines[0], ...importLines.slice(1).map((line) => `${unit}${line}`));
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isMultilineOpeningTagStart(value)) {
|
||||
const collected = collectOpeningTag(inputLines, index);
|
||||
|
||||
value = collected.value;
|
||||
index = collected.endIndex;
|
||||
}
|
||||
|
||||
const inlineDeclaration = formatInlineDeclarationBlock(value, unit, codeDepth + htmlDepth);
|
||||
const inlineProps =
|
||||
inlineDeclaration ?? formatInlinePropsBlock(value, unit, codeDepth + htmlDepth);
|
||||
if (inlineProps) {
|
||||
output.push(...inlineProps);
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const leadingClosingBraces = countLeadingClosingBraces(value);
|
||||
|
||||
const closesControlBlock = isControlBlockClose(value);
|
||||
|
||||
const continuesControlBlock = isControlBlockMiddle(value);
|
||||
|
||||
const lineControlDepth =
|
||||
closesControlBlock || continuesControlBlock ? Math.max(0, controlDepth - 1) : controlDepth;
|
||||
|
||||
const lineCodeDepth = Math.max(0, codeDepth - leadingClosingBraces);
|
||||
|
||||
let lineHtmlDepth = htmlDepth;
|
||||
|
||||
if (isClosingTag(value)) {
|
||||
lineHtmlDepth = Math.max(0, htmlDepth - 1);
|
||||
}
|
||||
|
||||
const depth = lineCodeDepth + lineHtmlDepth + lineControlDepth;
|
||||
|
||||
if (
|
||||
value.startsWith("<") &&
|
||||
!value.startsWith("</") &&
|
||||
!value.startsWith("<!--") &&
|
||||
!value.startsWith("<!") &&
|
||||
!value.startsWith("<?")
|
||||
) {
|
||||
const formattedTag = formatOpeningTag(value, unit, depth, printWidth, multilineAttributes);
|
||||
|
||||
output.push(...formattedTag.lines);
|
||||
|
||||
if (formattedTag.opensElement) {
|
||||
htmlDepth += 1;
|
||||
}
|
||||
} else {
|
||||
output.push(`${unit.repeat(depth)}${value}`);
|
||||
}
|
||||
|
||||
if (isClosingTag(value)) {
|
||||
htmlDepth = lineHtmlDepth;
|
||||
}
|
||||
|
||||
const braces = countStructuralBraces(value);
|
||||
|
||||
codeDepth = Math.max(
|
||||
0,
|
||||
lineCodeDepth + braces.openings - Math.max(0, braces.closings - leadingClosingBraces),
|
||||
);
|
||||
|
||||
if (isControlBlockOpen(value) || continuesControlBlock) {
|
||||
controlDepth = lineControlDepth + 1;
|
||||
} else if (closesControlBlock) {
|
||||
controlDepth = lineControlDepth;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
while (output.length > 0 && output[output.length - 1] === "") {
|
||||
output.pop();
|
||||
}
|
||||
|
||||
return `${output.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/** Format to a bounded fixed point so one call is always safe for editor-on-save and migrations. */
|
||||
export function formatWrn(source: string, options: FormatWrnOptions = {}): string {
|
||||
let current = source;
|
||||
const seen = new Set<string>();
|
||||
for (let pass = 0; pass < 8; pass++) {
|
||||
const formatted = formatWrnPass(current, options);
|
||||
if (formatted === current) return formatted;
|
||||
if (seen.has(formatted)) return [...seen, formatted].sort()[0]!;
|
||||
seen.add(current);
|
||||
current = formatted;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
export { Lexer, LexError } from "./tokenizer.ts";
|
||||
export { formatWrn } from "./formatter.ts";
|
||||
export type { FormatWrnOptions } from "./formatter.ts";
|
||||
export { parse, parseHtmlView, ParseError, VOID_ELEMENTS } from "./parser.ts";
|
||||
export type {
|
||||
ActionBlock,
|
||||
|
||||
+112
-11
@@ -1,3 +1,5 @@
|
||||
import { WRN_RUNTIME_TARGETS } from "./spec.ts";
|
||||
|
||||
/**
|
||||
* Recursive-descent parser for `.wrn`, producing a small AST.
|
||||
*
|
||||
@@ -63,12 +65,16 @@ export interface EffectBlock {
|
||||
|
||||
export interface LoadBlock {
|
||||
mode: "server" | "client";
|
||||
name?: string;
|
||||
dependsOn?: string[];
|
||||
deferred?: boolean;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ActionBlock {
|
||||
name: string;
|
||||
args: string[];
|
||||
schema?: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
@@ -205,9 +211,13 @@ export interface PageAst {
|
||||
layout?: string;
|
||||
layoutIsSymbol?: boolean;
|
||||
/** Execution boundary metadata. Defaults to universal. */
|
||||
runtime?: "server" | "client" | "universal";
|
||||
runtime?: "server" | "client" | "universal" | "edge" | "worker" | "service-worker";
|
||||
/** Explicit rendering policy; `hybrid` is the default SSR + optional hydration behavior. */
|
||||
renderMode?: "static" | "server" | "hybrid" | "client" | "partial-static";
|
||||
/** Client hydration strategy. Defaults to load when interactivity is present. */
|
||||
hydrate?: string;
|
||||
/** Declarative framework cache policy. */
|
||||
cache?: Record<string, string>;
|
||||
/** Declared component props (empty for pages). */
|
||||
props: PropDecl[];
|
||||
/** Legacy public events exposed by a reusable component. */
|
||||
@@ -222,6 +232,7 @@ export interface PageAst {
|
||||
loads: LoadBlock[];
|
||||
actions: ActionBlock[];
|
||||
security: Record<string, string>;
|
||||
navigation: Record<string, string>;
|
||||
seo: SeoBlock;
|
||||
view: ViewNode[];
|
||||
styles: string[];
|
||||
@@ -343,6 +354,7 @@ export function parse(source: string): PageAst {
|
||||
let layout: string | undefined;
|
||||
let layoutIsSymbol = false;
|
||||
let runtime: PageAst["runtime"];
|
||||
let renderMode: PageAst["renderMode"];
|
||||
let hydrate: string | undefined;
|
||||
const props: PropDecl[] = [];
|
||||
const events: EventDecl[] = [];
|
||||
@@ -354,6 +366,8 @@ export function parse(source: string): PageAst {
|
||||
const loads: LoadBlock[] = [];
|
||||
const actions: ActionBlock[] = [];
|
||||
const security: Record<string, string> = {};
|
||||
const cache: Record<string, string> = {};
|
||||
const navigation: Record<string, string> = {};
|
||||
const seo: SeoBlock = {};
|
||||
const view: ViewNode[] = [];
|
||||
const styles: string[] = [];
|
||||
@@ -393,19 +407,32 @@ export function parse(source: string): PageAst {
|
||||
lx.next();
|
||||
expect("eq");
|
||||
const value = expect("string").value;
|
||||
if (value !== "server" && value !== "client" && value !== "universal") {
|
||||
if (!WRN_RUNTIME_TARGETS.includes(value as (typeof WRN_RUNTIME_TARGETS)[number])) {
|
||||
throw new ParseError(
|
||||
`Unknown runtime target '${value}' at offset ${kw.pos}`,
|
||||
"WRN-RUNTIME-TARGET",
|
||||
);
|
||||
}
|
||||
runtime = value;
|
||||
runtime = value as PageAst["runtime"];
|
||||
break;
|
||||
}
|
||||
case "render": {
|
||||
lx.next();
|
||||
expect("eq");
|
||||
const value = expect("string").value;
|
||||
if (!["static", "server", "hybrid", "client", "partial-static"].includes(value))
|
||||
throw new ParseError(
|
||||
`Unknown render mode '${value}' at offset ${kw.pos}`,
|
||||
"WRN-RENDER-MODE",
|
||||
);
|
||||
renderMode = value as PageAst["renderMode"];
|
||||
break;
|
||||
}
|
||||
case "hydrate": {
|
||||
lx.next();
|
||||
expect("eq");
|
||||
hydrate = expect("string").value;
|
||||
const value = expect("string").value;
|
||||
hydrate = value === "never" ? "none" : value;
|
||||
break;
|
||||
}
|
||||
case "props": {
|
||||
@@ -549,15 +576,49 @@ export function parse(source: string): PageAst {
|
||||
Object.assign(security, parseSeoBlock(lx.readBalancedBraces()));
|
||||
break;
|
||||
}
|
||||
case "navigation": {
|
||||
lx.next();
|
||||
Object.assign(navigation, parseSeoBlock(lx.readBalancedBraces()));
|
||||
break;
|
||||
}
|
||||
case "cache": {
|
||||
lx.next();
|
||||
Object.assign(cache, parseSeoBlock(lx.readBalancedBraces()));
|
||||
break;
|
||||
}
|
||||
case "load": {
|
||||
lx.next();
|
||||
const modeToken = expect("ident");
|
||||
if (modeToken.value !== "server" && modeToken.value !== "client") {
|
||||
throw new ParseError(
|
||||
`Expected 'server' or 'client' after load at offset ${modeToken.pos}`,
|
||||
);
|
||||
const first = expect("ident");
|
||||
const mode = first.value === "client" ? "client" : "server";
|
||||
const name =
|
||||
first.value === "server" || first.value === "client"
|
||||
? lx.peek().type === "ident"
|
||||
? expect("ident").value
|
||||
: undefined
|
||||
: first.value;
|
||||
const dependsOn: string[] = [];
|
||||
let deferred = false;
|
||||
while (lx.peek().type === "ident") {
|
||||
if (lx.peek().value === "defer") {
|
||||
lx.next();
|
||||
deferred = true;
|
||||
continue;
|
||||
}
|
||||
if (lx.peek().value !== "after") break;
|
||||
lx.next();
|
||||
dependsOn.push(expect("ident").value);
|
||||
while (lx.peek().type === "comma") {
|
||||
lx.next();
|
||||
dependsOn.push(expect("ident").value);
|
||||
}
|
||||
}
|
||||
loads.push({ mode: modeToken.value, body: lx.readBalancedBraces() });
|
||||
loads.push({
|
||||
mode,
|
||||
name,
|
||||
...(dependsOn.length ? { dependsOn } : {}),
|
||||
...(deferred ? { deferred: true } : {}),
|
||||
body: lx.readBalancedBraces(),
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "action": {
|
||||
@@ -572,7 +633,12 @@ export function parse(source: string): PageAst {
|
||||
}
|
||||
expect("rparen");
|
||||
}
|
||||
actions.push({ name: actionName, args, body: lx.readBalancedBraces() });
|
||||
let schema: string | undefined;
|
||||
if (lx.peek().type === "ident" && lx.peek().value === "using") {
|
||||
lx.next();
|
||||
schema = expect("ident").value;
|
||||
}
|
||||
actions.push({ name: actionName, args, schema, body: lx.readBalancedBraces() });
|
||||
break;
|
||||
}
|
||||
case "api": {
|
||||
@@ -806,6 +872,38 @@ export function parse(source: string): PageAst {
|
||||
);
|
||||
functionKeys.add(key);
|
||||
}
|
||||
const namedLoads = new Map(loads.filter((load) => load.name).map((load) => [load.name!, load]));
|
||||
for (const load of namedLoads.values()) {
|
||||
for (const dependency of load.dependsOn ?? []) {
|
||||
const dependencyLoad = namedLoads.get(dependency);
|
||||
if (!dependencyLoad)
|
||||
throw new ParseError(
|
||||
`Load '${load.name}' depends on unknown load '${dependency}'`,
|
||||
"WRN-LOAD-DEPENDENCY",
|
||||
);
|
||||
if (
|
||||
load.mode === "server" &&
|
||||
!load.deferred &&
|
||||
(dependencyLoad.mode !== "server" || dependencyLoad.deferred)
|
||||
)
|
||||
throw new ParseError(
|
||||
`Server load '${load.name}' cannot depend on deferred/client load '${dependency}'`,
|
||||
"WRN-LOAD-PHASE",
|
||||
);
|
||||
}
|
||||
}
|
||||
const visiting = new Set<string>();
|
||||
const visited = new Set<string>();
|
||||
const visitLoad = (name: string): void => {
|
||||
if (visiting.has(name))
|
||||
throw new ParseError(`Load dependency cycle includes '${name}'`, "WRN-LOAD-CYCLE");
|
||||
if (visited.has(name)) return;
|
||||
visiting.add(name);
|
||||
for (const dependency of namedLoads.get(name)?.dependsOn ?? []) visitLoad(dependency);
|
||||
visiting.delete(name);
|
||||
visited.add(name);
|
||||
};
|
||||
for (const name of namedLoads.keys()) visitLoad(name);
|
||||
return {
|
||||
type: "page",
|
||||
imports,
|
||||
@@ -816,7 +914,9 @@ export function parse(source: string): PageAst {
|
||||
layout,
|
||||
layoutIsSymbol,
|
||||
runtime,
|
||||
renderMode,
|
||||
hydrate,
|
||||
cache,
|
||||
props,
|
||||
events,
|
||||
outputs,
|
||||
@@ -827,6 +927,7 @@ export function parse(source: string): PageAst {
|
||||
loads,
|
||||
actions,
|
||||
security,
|
||||
navigation,
|
||||
seo,
|
||||
view,
|
||||
styles,
|
||||
|
||||
@@ -38,7 +38,14 @@ export const WRN_ROOT_MEMBERS = [
|
||||
|
||||
export const WRN_HYDRATION_STRATEGIES = ["load", "idle", "visible", "interaction", "none"] as const;
|
||||
|
||||
export const WRN_RUNTIME_TARGETS = ["server", "client", "universal"] as const;
|
||||
export const WRN_RUNTIME_TARGETS = [
|
||||
"server",
|
||||
"client",
|
||||
"universal",
|
||||
"edge",
|
||||
"worker",
|
||||
"service-worker",
|
||||
] as const;
|
||||
|
||||
export type WrnRootKind = (typeof WRN_ROOT_KINDS)[number];
|
||||
export type WrnRootMember = (typeof WRN_ROOT_MEMBERS)[number];
|
||||
|
||||
Reference in New Issue
Block a user