release: WRNexusJS 0.3.5
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
"name": "wrnexus",
|
||||
"displayName": "WRNexus Language Support",
|
||||
"description": "Complete language support for WRNexus .wrn files, including highlighting, formatting, diagnostics, snippets, lifecycle hooks, state watchers, component functions, completions, and definition navigation.",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.4",
|
||||
"publisher": "wrnexus",
|
||||
"private": true,
|
||||
"license": "SEE LICENSE IN LICENSE",
|
||||
@@ -117,13 +117,11 @@
|
||||
"scope": "resource",
|
||||
"description": "Enable the built-in WRNexus formatter for .wrn files."
|
||||
},
|
||||
"wrnexus.formatting.printWidth": {
|
||||
"type": "number",
|
||||
"default": 100,
|
||||
"minimum": 60,
|
||||
"maximum": 240,
|
||||
"wrnexus.formatting.multilineAttributes": {
|
||||
"type": "boolean",
|
||||
"default": true,
|
||||
"scope": "resource",
|
||||
"description": "Preferred maximum line width used by the WRNexus formatter."
|
||||
"description": "Place opening-tag attributes on separate lines, with the closing delimiter on its own line."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
+209
-18
@@ -30,6 +30,36 @@ const types_ts_1 = require("./types.js");
|
||||
function isComponentTag(tag) {
|
||||
return /^[A-Z][A-Za-z0-9_$]*$/.test(tag);
|
||||
}
|
||||
const HTML_BOOLEAN_ATTRIBUTES = new Set([
|
||||
"allowfullscreen",
|
||||
"async",
|
||||
"autofocus",
|
||||
"autoplay",
|
||||
"checked",
|
||||
"controls",
|
||||
"default",
|
||||
"defer",
|
||||
"disabled",
|
||||
"formnovalidate",
|
||||
"hidden",
|
||||
"inert",
|
||||
"ismap",
|
||||
"itemscope",
|
||||
"loop",
|
||||
"multiple",
|
||||
"muted",
|
||||
"nomodule",
|
||||
"novalidate",
|
||||
"open",
|
||||
"playsinline",
|
||||
"readonly",
|
||||
"required",
|
||||
"reversed",
|
||||
"selected",
|
||||
]);
|
||||
function isHtmlBooleanAttribute(name) {
|
||||
return HTML_BOOLEAN_ATTRIBUTES.has(name.toLowerCase());
|
||||
}
|
||||
/** Escape a value placed inside a double-quoted HTML attribute. */
|
||||
function attrEscape(value) {
|
||||
return value
|
||||
@@ -94,13 +124,22 @@ function reactiveAttrValue(raw, reactive) {
|
||||
});
|
||||
return found ? value : null;
|
||||
}
|
||||
function renderAttrs(attrs, csrId, reactive = null) {
|
||||
function renderAttrs(attrs, csrId, reactive = null, dynamicExpressions) {
|
||||
let bindIndex = 0;
|
||||
const rendered = attrs
|
||||
.map((attr) => {
|
||||
const base = renderAttr(attr);
|
||||
if (!reactive || attr.event || attr.boolean || !base || !attr.value.includes("{"))
|
||||
return base;
|
||||
const expression = wholeAttributeExpression(attr.value);
|
||||
if (expression &&
|
||||
exprRefsState(expression, reactive.runtimeStateNames) &&
|
||||
dynamicExpressions) {
|
||||
dynamicExpressions.push(`\${__wrnexusPropAttr(${expression})}`);
|
||||
const sentinel = `\x00WRNEACH${dynamicExpressions.length - 1}\x00`;
|
||||
const marker = JSON.stringify([attr.name, attr.value]);
|
||||
return ` ${attr.name}="${sentinel}" data-wrn-bind-${bindIndex++}="${attrEscape(marker)}"`;
|
||||
}
|
||||
const initial = reactiveAttrValue(attr.value, reactive);
|
||||
if (initial === null)
|
||||
return base;
|
||||
@@ -332,7 +371,7 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
: undefined;
|
||||
// Void elements (<br>, <img>, …) have no closing tag and no children.
|
||||
if (parser_ts_1.VOID_ELEMENTS.has(node.tag.toLowerCase())) {
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>`;
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>`;
|
||||
}
|
||||
const inner = apiBinding?.mode === "ssr"
|
||||
? ssrMarker(ssrBindings, renderBinding(apiBinding))
|
||||
@@ -346,7 +385,7 @@ function renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive
|
||||
: node.children
|
||||
.map((child) => renderNode(child, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
.join("");
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive)}>${inner}</${node.tag}>`;
|
||||
return `<${node.tag}${renderAttrs(node.attrs, csrId, reactive, loops)}>${inner}</${node.tag}>`;
|
||||
}
|
||||
function renderPageComponentInvocation(node, ssrBindings, csrBindings, apiBindings, loops, reactive) {
|
||||
const attrs = node.attrs
|
||||
@@ -363,6 +402,10 @@ function renderNestedComponentInvocation(node, ctx) {
|
||||
const attrs = node.attrs
|
||||
.filter((attr) => attr.name !== "data-component")
|
||||
.map((attr) => {
|
||||
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(attr.name);
|
||||
if (spread) {
|
||||
return `\${__wireSpreadAttrs(${ctx.resolveExpr(spread[1])})}`;
|
||||
}
|
||||
if (attr.event) {
|
||||
return (escLit(` ${eventAttribute(attr.name)}="`) + escLit(attrEscape(attr.value)) + escLit(`"`));
|
||||
}
|
||||
@@ -387,11 +430,14 @@ function renderNestedComponentInvocation(node, ctx) {
|
||||
const childCtx = loops.length > 0
|
||||
? {
|
||||
...ctx,
|
||||
forwardRestAttrs: false,
|
||||
loopVars: new Set([...(ctx.loopVars ?? []), ...loops]),
|
||||
}
|
||||
: ctx;
|
||||
: { ...ctx, forwardRestAttrs: false };
|
||||
const inner = node.children.map((child) => renderComponentNode(child, childCtx)).join("");
|
||||
return `<div data-component="${attrEscape(node.tag)}"${attrs}>${inner}</div>`;
|
||||
return (`<div data-component="${attrEscape(node.tag)}"` +
|
||||
`${ctx.forwardRestAttrs ? "${__wireSpreadAttrs(__attrs)}" : ""}` +
|
||||
`${attrs}>${inner}</div>`);
|
||||
}
|
||||
function ssrMarker(bindings, binding) {
|
||||
const marker = `<!--wrnexus-ssr:${bindings.length}-->`;
|
||||
@@ -549,12 +595,23 @@ function hydrationId(ast) {
|
||||
kind: ast.kind,
|
||||
name: ast.name,
|
||||
props: ast.props.map((entry) => entry.name),
|
||||
events: ast.events.map((entry) => entry.name),
|
||||
states: ast.states.map((entry) => entry.name),
|
||||
computed: ast.computed.map((entry) => entry.name),
|
||||
view: ast.view,
|
||||
});
|
||||
return `${ast.name}:${stableHash(shape)}`;
|
||||
}
|
||||
function isSafeGeneratedIdentifier(name) {
|
||||
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
||||
}
|
||||
function generateSsrStateAliases(stateNames) {
|
||||
const names = [...new Set(stateNames)].filter(isSafeGeneratedIdentifier);
|
||||
if (!names.length) {
|
||||
return "";
|
||||
}
|
||||
return `const { ${names.join(", ")} } = __state;\n`;
|
||||
}
|
||||
function hydrationAttribute(ast) {
|
||||
const strategy = ast.hydrate ?? "load";
|
||||
return ` data-wrn-hydration="${attrEscape(hydrationId(ast))}" data-wrn-hydrate="${attrEscape(strategy)}" data-wrn-runtime="${attrEscape(ast.runtime ?? "universal")}"`;
|
||||
@@ -606,7 +663,10 @@ function generate(ast) {
|
||||
...ast.states.map((entry) => entry.name),
|
||||
...ast.computed.map((entry) => entry.name),
|
||||
];
|
||||
const reactive = reactiveNames.length > 0 ? { stateNames: new Set(reactiveNames), scope: seedScope } : null;
|
||||
const runtimeStateNames = new Set(ast.states.filter((entry) => /\bctx\b/.test(entry.expr)).map((entry) => entry.name));
|
||||
const reactive = reactiveNames.length > 0
|
||||
? { stateNames: new Set(reactiveNames), runtimeStateNames, scope: seedScope }
|
||||
: null;
|
||||
const loops = [];
|
||||
let html = ast.view
|
||||
.map((node) => renderNode(node, ssrBindings, csrBindings, apiBindings, loops, reactive))
|
||||
@@ -643,6 +703,7 @@ function generate(ast) {
|
||||
.map((state) => `${JSON.stringify(state.name)}: ${state.valueType ?? "unknown"}`)
|
||||
.join("; ")} }`
|
||||
: "Record<string, never>";
|
||||
const ssrStateAliases = generateSsrStateAliases(ast.states.map((state) => state.name));
|
||||
loops.forEach((code, idx) => {
|
||||
body = body.replace(`\x00WRNEACH${idx}\x00`, () => code);
|
||||
});
|
||||
@@ -669,7 +730,7 @@ function generate(ast) {
|
||||
out.push(`export default async function ${ast.name}(ctx: any) {
|
||||
${decls}
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
|
||||
${ssrStateAliases}
|
||||
const __scopeValue = Object.entries(__state)
|
||||
.map(([key, value]) => {
|
||||
const encoded =
|
||||
@@ -696,7 +757,7 @@ function generate(ast) {
|
||||
else {
|
||||
out.push(`export default function ${ast.name}(ctx: any) {
|
||||
const __state: ${stateType} = { ${dynamicStateScope} };
|
||||
|
||||
${ssrStateAliases}
|
||||
const __scopeValue = Object.entries(__state)
|
||||
.map(([key, value]) => {
|
||||
const encoded =
|
||||
@@ -901,6 +962,20 @@ function viewHasServerEach(nodes) {
|
||||
return viewHasServerEach(node.children);
|
||||
});
|
||||
}
|
||||
function viewHasRestAttributeSpread(nodes) {
|
||||
return nodes.some((node) => {
|
||||
if (node.type === "text")
|
||||
return false;
|
||||
if (node.type === "each") {
|
||||
return viewHasRestAttributeSpread(node.body) || viewHasRestAttributeSpread(node.empty);
|
||||
}
|
||||
if (node.type === "if") {
|
||||
return node.branches.some((branch) => viewHasRestAttributeSpread(branch.body));
|
||||
}
|
||||
return (node.attrs.some((attr) => /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.test(attr.name)) ||
|
||||
viewHasRestAttributeSpread(node.children));
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Compile a text node. Interpolations that reference state stay as client
|
||||
* mustaches (`{expr}`, hydrated by the reactive runtime); interpolations of
|
||||
@@ -1060,12 +1135,13 @@ function renderComponentNode(node, ctx) {
|
||||
return renderNestedComponentInvocation(node, ctx);
|
||||
}
|
||||
const loopVariables = loopVarsOf(node);
|
||||
const elementContext = loopVariables.length > 0
|
||||
? {
|
||||
...ctx,
|
||||
loopVars: new Set([...(ctx.loopVars ?? []), ...loopVariables]),
|
||||
}
|
||||
: ctx;
|
||||
const elementContext = {
|
||||
...ctx,
|
||||
forwardRestAttrs: false,
|
||||
...(loopVariables.length > 0
|
||||
? { loopVars: new Set([...(ctx.loopVars ?? []), ...loopVariables]) }
|
||||
: {}),
|
||||
};
|
||||
let bindIndex = 0;
|
||||
const staticClasses = [];
|
||||
const conditionalClasses = [];
|
||||
@@ -1083,12 +1159,36 @@ function renderComponentNode(node, ctx) {
|
||||
const attrs = node.attrs
|
||||
.filter((a) => a.name !== "class" && !a.name.startsWith("class:"))
|
||||
.map((a) => {
|
||||
const spread = /^\{\.\.\.([A-Za-z_$][\w$]*)\}$/.exec(a.name);
|
||||
if (spread) {
|
||||
return `\${__wireSpreadAttrs(${elementContext.resolveExpr(spread[1])})}`;
|
||||
}
|
||||
if (a.event) {
|
||||
return ` ${eventAttribute(a.name)}="${escLit(attrEscape(a.value))}"`;
|
||||
}
|
||||
if (a.boolean) {
|
||||
return ` ${a.name}`;
|
||||
}
|
||||
if (isHtmlBooleanAttribute(a.name)) {
|
||||
const expression = wholeAttributeExpression(a.value);
|
||||
if (expression) {
|
||||
const referencesState = exprRefsState(a.value, ctx.stateNames);
|
||||
const referencesLoopVariable = elementContext.loopVars
|
||||
? exprRefsState(a.value, elementContext.loopVars)
|
||||
: false;
|
||||
const referencesServerLocal = ctx.serverLocals
|
||||
? exprRefsState(a.value, ctx.serverLocals)
|
||||
: false;
|
||||
const marker = referencesState || referencesLoopVariable || referencesServerLocal
|
||||
? ` data-wrn-bind-${bindIndex++}="${escLit(attrEscape(JSON.stringify([a.name, a.value])))}"`
|
||||
: "";
|
||||
return `\${__wireBooleanAttr(${JSON.stringify(a.name)}, ${elementContext.resolveExpr(expression)})}${marker}`;
|
||||
}
|
||||
if (a.value === "false")
|
||||
return "";
|
||||
if (a.value === "true" || a.value === "")
|
||||
return ` ${a.name}`;
|
||||
}
|
||||
const rendered = ` ${a.name}="${compileAttrValue(a.value, elementContext)}"`;
|
||||
const referencesState = exprRefsState(a.value, ctx.stateNames);
|
||||
const referencesLoopVariable = elementContext.loopVars
|
||||
@@ -1142,6 +1242,10 @@ function renderComponentNode(node, ctx) {
|
||||
.join("");
|
||||
const loopLocalsAttribute = serverLoopLocalsAttribute(ctx);
|
||||
const allAttrs = `${loopLocalsAttribute}` +
|
||||
`${ctx.forwardRestAttrs ? "${__wireSpreadAttrs(__attrs)}" : ""}` +
|
||||
`${ctx.forwardRestAttrs && ctx.eventNames?.length
|
||||
? ` data-wrn-events="${attrEscape(ctx.eventNames.join(","))}"`
|
||||
: ""}` +
|
||||
`${classAttribute}` +
|
||||
`${classReactiveBinding}` +
|
||||
`${classBindings}` +
|
||||
@@ -1179,6 +1283,9 @@ function generateComponent(ast) {
|
||||
for (const p of effectiveProps) {
|
||||
nameRefs.set(p.name, safeRef(p.name));
|
||||
}
|
||||
if (!nameRefs.has("attrs")) {
|
||||
nameRefs.set("attrs", "__attrs");
|
||||
}
|
||||
for (const s of ast.states)
|
||||
nameRefs.set(s.name, safeRef(s.name));
|
||||
for (const entry of ast.computed)
|
||||
@@ -1191,12 +1298,25 @@ function generateComponent(ast) {
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const ctx = { stateNames, resolveExpr };
|
||||
const ctx = {
|
||||
stateNames,
|
||||
resolveExpr,
|
||||
eventNames: ast.events.map((event) => event.name),
|
||||
};
|
||||
const serverFunctions = ast.functions
|
||||
.map((body) => body.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
const viewCode = ast.view.map((node) => renderComponentNode(node, ctx)).join("");
|
||||
const hasExplicitRestSpread = viewHasRestAttributeSpread(ast.view);
|
||||
const rootElementIndex = ast.view.findIndex((node) => node.type === "element");
|
||||
const automaticallyForwardRootAttrs = !hasExplicitRestSpread &&
|
||||
!effectiveProps.some((prop) => prop.name === "attrs") &&
|
||||
rootElementIndex >= 0;
|
||||
const viewCode = ast.view
|
||||
.map((node, index) => renderComponentNode(node, automaticallyForwardRootAttrs && index === rootElementIndex
|
||||
? { ...ctx, forwardRestAttrs: true }
|
||||
: ctx))
|
||||
.join("");
|
||||
const styles = ast.styles.map((body) => body.trim()).filter(Boolean);
|
||||
const styleTag = styles.length > 0
|
||||
? escLit(`<style data-wrnexus-style="${attrEscape(ast.name)}">\n${styles.map(styleEscape).join("\n")}\n</style>`)
|
||||
@@ -1222,6 +1342,9 @@ function generateComponent(ast) {
|
||||
}
|
||||
decls.push(` const ${nameRefs.get(prop.name)}: ${prop.valueType ?? "any"} = __coerce(__p[${JSON.stringify(prop.name)}], (${resolveExpr(prop.default)}), ${JSON.stringify((0, types_ts_1.runtimeTypeOf)(prop.valueType))});`);
|
||||
}
|
||||
if (!effectiveProps.some((prop) => prop.name === "attrs")) {
|
||||
decls.push(` const __attrs = __restProps(__p, new Set(${JSON.stringify(effectiveProps.map((prop) => prop.name))}));`);
|
||||
}
|
||||
for (const state of ast.states) {
|
||||
decls.push(` let ${nameRefs.get(state.name)}${state.valueType ? `: ${state.valueType}` : ""} = (${resolveExpr(state.expr)});`);
|
||||
}
|
||||
@@ -1264,7 +1387,7 @@ function generateComponent(ast) {
|
||||
if (typeSource)
|
||||
out.push(typeSource);
|
||||
if (effectiveProps.length > 0) {
|
||||
out.push(`export interface ${ast.name}Props {\n${effectiveProps
|
||||
out.push(`export interface ${ast.name}Props {\n [attribute: string]: unknown;\n${effectiveProps
|
||||
.map((prop) => ` ${JSON.stringify(prop.name)}${prop.required ? "" : "?"}: ${prop.valueType ?? "unknown"};`)
|
||||
.join("\n")}\n}`);
|
||||
}
|
||||
@@ -1339,6 +1462,15 @@ function generateComponent(ast) {
|
||||
return declared === "unknown" && def === undefined ? v : String(v);
|
||||
}
|
||||
|
||||
function __restProps(
|
||||
props: Record<string, any>,
|
||||
declared: Set<string>,
|
||||
): Record<string, any> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(props).filter(([name]) => !declared.has(name)),
|
||||
);
|
||||
}
|
||||
|
||||
function __wireHtml(v: any): string {
|
||||
return String(v == null ? "" : v).replace(
|
||||
/[&<>]/g,
|
||||
@@ -1365,6 +1497,48 @@ function __wireAttr(v: any): string {
|
||||
);
|
||||
}
|
||||
|
||||
function __wireBooleanAttr(name: string, value: any): string {
|
||||
return value === true ||
|
||||
value === "true" ||
|
||||
value === "" ||
|
||||
value === 1 ||
|
||||
value === "1" ||
|
||||
value === name
|
||||
? " " + name
|
||||
: "";
|
||||
}
|
||||
|
||||
function __wireSpreadAttrs(value: any): string {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) return "";
|
||||
|
||||
const booleanAttributes = new Set(${JSON.stringify([...HTML_BOOLEAN_ATTRIBUTES])});
|
||||
const attributes: string[] = [];
|
||||
|
||||
for (const [name, raw] of Object.entries(value)) {
|
||||
const lowerName = name.toLowerCase();
|
||||
if (
|
||||
!/^[A-Za-z_:][A-Za-z0-9_.:-]*$/.test(name) ||
|
||||
lowerName.startsWith("on") ||
|
||||
lowerName === "style" ||
|
||||
lowerName === "slot" ||
|
||||
lowerName === "data-component" ||
|
||||
lowerName.startsWith("data-wrn")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (booleanAttributes.has(lowerName)) {
|
||||
attributes.push(__wireBooleanAttr(name, raw));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (raw === false || raw === null || raw === undefined) continue;
|
||||
attributes.push(" " + name + '="' + __wireAttr(raw) + '"');
|
||||
}
|
||||
|
||||
return attributes.join("");
|
||||
}
|
||||
|
||||
function __wireProp(v: any): string {
|
||||
const value =
|
||||
v !== null && typeof v === "object"
|
||||
@@ -2192,6 +2366,7 @@ function parse(source) {
|
||||
let runtime;
|
||||
let hydrate;
|
||||
const props = [];
|
||||
const events = [];
|
||||
const types = [];
|
||||
const states = [];
|
||||
const computed = [];
|
||||
@@ -2241,13 +2416,28 @@ function parse(source) {
|
||||
break;
|
||||
}
|
||||
case "props": {
|
||||
// props { name: Type = <default> } — omit the default for required props.
|
||||
// props { name: Type = <default>; @event name = function }
|
||||
lx.next();
|
||||
expect("lbrace");
|
||||
while (lx.peek().type !== "rbrace") {
|
||||
const t = lx.peek();
|
||||
if (t.type === "eof")
|
||||
throw new ParseError("Unexpected end of input inside props");
|
||||
if (t.type === "at") {
|
||||
lx.next();
|
||||
const declarationKind = expect("ident");
|
||||
if (declarationKind.value !== "event") {
|
||||
throw new ParseError(`Expected '@event' but got '@${declarationKind.value}' at offset ${declarationKind.pos}`);
|
||||
}
|
||||
const eventName = expect("ident").value;
|
||||
expect("eq");
|
||||
const marker = lx.readPropInitializer();
|
||||
if (marker !== "function") {
|
||||
throw new ParseError(`Event '${eventName}' must be declared as '@event ${eventName} = function'`);
|
||||
}
|
||||
events.push({ name: eventName });
|
||||
continue;
|
||||
}
|
||||
if (t.type !== "ident") {
|
||||
throw new ParseError(`Expected a prop name at offset ${t.pos}`);
|
||||
}
|
||||
@@ -2510,6 +2700,7 @@ function parse(source) {
|
||||
runtime,
|
||||
hydrate,
|
||||
props,
|
||||
events,
|
||||
types,
|
||||
states,
|
||||
computed,
|
||||
|
||||
@@ -69,6 +69,7 @@ function parseComponentMetadata(source, uri = null) {
|
||||
|
||||
const propsKeyword = /\bprops\s*\{/.exec(source.slice(declaration.index));
|
||||
const props = [];
|
||||
const events = [];
|
||||
if (propsKeyword) {
|
||||
const start = declaration.index + propsKeyword.index;
|
||||
const openingBrace = source.indexOf("{", start);
|
||||
@@ -94,9 +95,12 @@ function parseComponentMetadata(source, uri = null) {
|
||||
options: inferOptions(source, name),
|
||||
});
|
||||
}
|
||||
const eventPattern = /^\s*@event\s+([A-Za-z_$][\w$]*)\s*=\s*function\s*$/gm;
|
||||
let eventMatch;
|
||||
while ((eventMatch = eventPattern.exec(body)) !== null) events.push(eventMatch[1]);
|
||||
}
|
||||
|
||||
return { kind: declaration[1], name: declaration[2], props, uri };
|
||||
return { kind: declaration[1], name: declaration[2], props, events, uri };
|
||||
}
|
||||
|
||||
function unwrapAttributeValue(value) {
|
||||
@@ -173,6 +177,7 @@ function validateComponentTags(source, components) {
|
||||
if (!component) continue;
|
||||
const provided = new Map(tag.attributes.map((attribute) => [attribute.name, attribute]));
|
||||
const declared = new Map(component.props.map((prop) => [prop.name, prop]));
|
||||
const declaredEvents = new Set(component.events || []);
|
||||
|
||||
for (const prop of component.props) {
|
||||
if (prop.required && !provided.has(prop.name)) {
|
||||
@@ -187,6 +192,19 @@ function validateComponentTags(source, components) {
|
||||
}
|
||||
|
||||
for (const attribute of tag.attributes) {
|
||||
if (attribute.name.startsWith("@")) {
|
||||
const eventName = attribute.name.slice(1);
|
||||
if (!declaredEvents.has(eventName)) {
|
||||
diagnostics.push({
|
||||
severity: "warning",
|
||||
code: "wrn-unknown-component-event",
|
||||
message: `Unknown event \`${eventName}\` on <${tag.name}>.`,
|
||||
start: attribute.nameStart,
|
||||
end: attribute.nameEnd,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const prop = declared.get(attribute.name);
|
||||
if (!prop) {
|
||||
diagnostics.push({
|
||||
|
||||
@@ -84,7 +84,62 @@ function lineDiagnostic(
|
||||
}
|
||||
|
||||
function stripComments(source) {
|
||||
return source.replace(/<!--[\s\S]*?-->/g, (comment) => comment.replace(/[^\n]/g, " "));
|
||||
const masked = [...source];
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
const maskRange = (start, end) => {
|
||||
for (let index = start; index < end; index += 1) {
|
||||
if (source[index] !== "\n" && source[index] !== "\r") {
|
||||
masked[index] = " ";
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[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 (source.startsWith("//", index)) {
|
||||
const lineEnd = source.indexOf("\n", index + 2);
|
||||
const end = lineEnd === -1 ? source.length : lineEnd;
|
||||
maskRange(index, end);
|
||||
index = end - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("/*", index)) {
|
||||
const commentEnd = source.indexOf("*/", index + 2);
|
||||
const end = commentEnd === -1 ? source.length : commentEnd + 2;
|
||||
maskRange(index, end);
|
||||
index = end - 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("<!--", index)) {
|
||||
const commentEnd = source.indexOf("-->", index + 4);
|
||||
const end = commentEnd === -1 ? source.length : commentEnd + 3;
|
||||
maskRange(index, end);
|
||||
index = end - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return masked.join("");
|
||||
}
|
||||
|
||||
function maskLeadingTrivia(source) {
|
||||
@@ -509,6 +564,17 @@ function findRootMembers(source, bodyStart, bodyEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("//", index)) {
|
||||
skipLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("/*", index)) {
|
||||
const end = source.indexOf("*/", index + 2);
|
||||
index = end === -1 ? bodyEnd : end + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.startsWith("<!--", index)) {
|
||||
const end = source.indexOf("-->", index + 4);
|
||||
|
||||
|
||||
@@ -68,11 +68,14 @@ function registerFormatter(context) {
|
||||
|
||||
const printWidth = configuration.get("formatting.printWidth", 100);
|
||||
|
||||
const multilineAttributes = configuration.get("formatting.multilineAttributes", true);
|
||||
|
||||
try {
|
||||
const formatted = formatWrn(source, {
|
||||
tabSize: options.tabSize || 2,
|
||||
tabSize: options.tabSize || 4,
|
||||
insertSpaces: options.insertSpaces !== false,
|
||||
printWidth,
|
||||
multilineAttributes,
|
||||
});
|
||||
|
||||
if (typeof formatted !== "string" || formatted === source) {
|
||||
|
||||
+117
-34
@@ -33,6 +33,16 @@ function splitPropDeclarations(value) {
|
||||
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] === "=";
|
||||
}
|
||||
if (!isIdentifierStart(value[cursor])) return false;
|
||||
cursor += 1;
|
||||
while (cursor < value.length && isIdentifierPart(value[cursor])) cursor += 1;
|
||||
@@ -69,6 +79,7 @@ function splitPropDeclarations(value) {
|
||||
brace === 0 &&
|
||||
paren === 0 &&
|
||||
/\s/.test(character) &&
|
||||
value.slice(start, index).trim() !== "@event" &&
|
||||
beginsDeclaration(index)
|
||||
) {
|
||||
const declaration = value.slice(start, index).trim();
|
||||
@@ -154,6 +165,10 @@ function parseAttributes(value) {
|
||||
return attributes;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function parseOpeningTag(value) {
|
||||
const endIndex = findOpeningTagEnd(value);
|
||||
|
||||
@@ -162,7 +177,6 @@ function parseOpeningTag(value) {
|
||||
}
|
||||
|
||||
const openingPart = value.slice(0, endIndex + 1);
|
||||
|
||||
const remainder = value.slice(endIndex + 1).trim();
|
||||
|
||||
const match = /^<([A-Za-z][\w$:.-]*)([\s\S]*?)(\/?)>$/.exec(openingPart);
|
||||
@@ -172,26 +186,39 @@ function parseOpeningTag(value) {
|
||||
}
|
||||
|
||||
const tagName = match[1];
|
||||
|
||||
const attributes = parseAttributes(match[2].trim());
|
||||
|
||||
const selfClosing = match[3] === "/";
|
||||
|
||||
const inlineClosing = remainder === `</${tagName}>`;
|
||||
const escapedTagName = escapeRegExp(tagName);
|
||||
|
||||
const closesInRemainder = remainder.startsWith(`</${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) {
|
||||
function formatOpeningTag(value, unit, depth, printWidth = 100, multilineAttributes = true) {
|
||||
const parsed = parseOpeningTag(value);
|
||||
|
||||
if (!parsed) {
|
||||
@@ -202,23 +229,22 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) {
|
||||
}
|
||||
|
||||
const baseIndent = unit.repeat(depth);
|
||||
const childIndent = unit.repeat(depth + 1);
|
||||
|
||||
const attributeIndent = unit.repeat(depth + 1);
|
||||
|
||||
const normalizedOpening = `<${parsed.tagName}${
|
||||
parsed.attributes.length ? ` ${parsed.attributes.join(" ")}` : ""
|
||||
}${parsed.selfClosing ? " /" : ""}>`;
|
||||
const normalizedOpening =
|
||||
`<${parsed.tagName}` +
|
||||
`${parsed.attributes.length ? ` ${parsed.attributes.join(" ")}` : ""}` +
|
||||
`${parsed.selfClosing ? " /" : ""}>`;
|
||||
|
||||
const normalizedSingleLine = `${normalizedOpening}${parsed.remainder}`;
|
||||
|
||||
const shouldBreak =
|
||||
parsed.attributes.length > 1 ||
|
||||
normalizedSingleLine.length > printWidth ||
|
||||
value.includes("\n");
|
||||
value.includes("\n") ||
|
||||
(multilineAttributes && parsed.attributes.length > 0) ||
|
||||
baseIndent.length + normalizedSingleLine.length > printWidth;
|
||||
|
||||
const opensElement =
|
||||
!parsed.selfClosing &&
|
||||
!parsed.inlineClosing &&
|
||||
!parsed.closesInRemainder &&
|
||||
!VOID_ELEMENTS.has(parsed.tagName.toLowerCase());
|
||||
|
||||
@@ -231,19 +257,27 @@ function formatOpeningTag(value, unit, depth, printWidth = 100) {
|
||||
|
||||
const lines = [
|
||||
`${baseIndent}<${parsed.tagName}`,
|
||||
...parsed.attributes.map((attribute) => `${attributeIndent}${attribute}`),
|
||||
...parsed.attributes.map((attribute) => `${childIndent}${attribute}`),
|
||||
];
|
||||
|
||||
if (parsed.inlineClosing) {
|
||||
lines.push(`${baseIndent}></${parsed.tagName}>`);
|
||||
} else if (parsed.selfClosing) {
|
||||
if (parsed.selfClosing) {
|
||||
lines.push(`${baseIndent}/>`);
|
||||
} else {
|
||||
lines.push(`${baseIndent}>`);
|
||||
return {
|
||||
lines,
|
||||
opensElement,
|
||||
};
|
||||
}
|
||||
|
||||
if (parsed.remainder) {
|
||||
lines.push(`${baseIndent}${parsed.remainder}`);
|
||||
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 {
|
||||
@@ -273,8 +307,16 @@ function isClosingTag(value) {
|
||||
return /^<\/[A-Za-z][\w$:.-]*\s*>/.test(value);
|
||||
}
|
||||
|
||||
function isInlineElement(value) {
|
||||
return /^<([A-Za-z][\w$:.-]*)\b[^>]*>[\s\S]*<\/\1\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) {
|
||||
@@ -390,18 +432,47 @@ function collectOpeningTag(inputLines, startIndex) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 formatWrn(source, options = {}) {
|
||||
const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize || 4);
|
||||
const unit = options.insertSpaces === false ? "\t" : " ".repeat(options.tabSize ?? 4);
|
||||
|
||||
const printWidth = options.printWidth || 100;
|
||||
const printWidth = options.printWidth ?? 100;
|
||||
|
||||
const inputLines = source.replace(/\r\n/g, "\n").split("\n");
|
||||
|
||||
const output = [];
|
||||
const multilineAttributes = options.multilineAttributes !== false;
|
||||
|
||||
let codeDepth = 0;
|
||||
let htmlDepth = 0;
|
||||
let controlDepth = 0;
|
||||
let index = 0;
|
||||
|
||||
const inputLines = expandInlineControlBlocks(source.replace(/\r\n/g, "\n").split("\n"));
|
||||
|
||||
const output = [];
|
||||
|
||||
let previousWasBlank = false;
|
||||
|
||||
while (index < inputLines.length) {
|
||||
@@ -451,6 +522,13 @@ function formatWrn(source, options = {}) {
|
||||
|
||||
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;
|
||||
@@ -459,17 +537,16 @@ function formatWrn(source, options = {}) {
|
||||
lineHtmlDepth = Math.max(0, htmlDepth - 1);
|
||||
}
|
||||
|
||||
const depth = lineCodeDepth + lineHtmlDepth;
|
||||
const depth = lineCodeDepth + lineHtmlDepth + lineControlDepth;
|
||||
|
||||
if (
|
||||
value.startsWith("<") &&
|
||||
!value.startsWith("</") &&
|
||||
!value.startsWith("<!--") &&
|
||||
!value.startsWith("<!") &&
|
||||
!value.startsWith("<?") &&
|
||||
!isInlineElement(value)
|
||||
!value.startsWith("<?")
|
||||
) {
|
||||
const formattedTag = formatOpeningTag(value, unit, depth, printWidth);
|
||||
const formattedTag = formatOpeningTag(value, unit, depth, printWidth, multilineAttributes);
|
||||
|
||||
output.push(...formattedTag.lines);
|
||||
|
||||
@@ -491,6 +568,12 @@ function formatWrn(source, options = {}) {
|
||||
lineCodeDepth + braces.openings - Math.max(0, braces.closings - leadingClosingBraces),
|
||||
);
|
||||
|
||||
if (isControlBlockOpen(value) || continuesControlBlock) {
|
||||
controlDepth = lineControlDepth + 1;
|
||||
} else if (closesControlBlock) {
|
||||
controlDepth = lineControlDepth;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
|
||||
@@ -200,6 +200,23 @@
|
||||
{
|
||||
"include": "#comments"
|
||||
},
|
||||
{
|
||||
"match": "(@event)\\s+([A-Za-z_$][A-Za-z0-9_$]*)\\s*(=)\\s*(function)\\b",
|
||||
"captures": {
|
||||
"1": {
|
||||
"name": "keyword.control.event.wrn"
|
||||
},
|
||||
"2": {
|
||||
"name": "entity.name.function.event.wrn"
|
||||
},
|
||||
"3": {
|
||||
"name": "keyword.operator.assignment.wrn"
|
||||
},
|
||||
"4": {
|
||||
"name": "storage.type.function.wrn"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"match": "\\b([A-Za-z_$][A-Za-z0-9_$]*)(?:\\s*(:)\\s*([^=\\r\\n]+?))?\\s*(=|$)",
|
||||
"captures": {
|
||||
|
||||
@@ -108,3 +108,23 @@ test("extracts explicit prop types and resolves typed prop/state expressions", (
|
||||
);
|
||||
assert.deepEqual(validateComponentTags(input, new Map([["DateInput", target]])), []);
|
||||
});
|
||||
|
||||
test("extracts declared events and validates component event bindings", () => {
|
||||
const target = parseComponentMetadata(`component PinInput {
|
||||
props {
|
||||
value = ""
|
||||
@event complete = function
|
||||
@event clear = function
|
||||
}
|
||||
view { <div></div> }
|
||||
}`);
|
||||
|
||||
assert.deepEqual(target.events, ["complete", "clear"]);
|
||||
assert.deepEqual(
|
||||
validateComponentTags(
|
||||
`<PinInput @complete="save(event)" @missing="fail(event)" />`,
|
||||
new Map([["PinInput", target]]),
|
||||
).map(({ code }) => code),
|
||||
["wrn-unknown-component-event"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
findTopLevelDeclaration,
|
||||
maskLeadingTrivia,
|
||||
validateBalancedCharacters,
|
||||
validateHtmlTags,
|
||||
validateRootMembers,
|
||||
} = require("../src/diagnostics");
|
||||
Module._load = originalLoad;
|
||||
@@ -115,3 +116,37 @@ test("accepts all WRN 0.3 root members without false unknown-member errors", ()
|
||||
|
||||
assert.deepEqual(validateRootMembers(document, source, declaration.kind, declaration.match), []);
|
||||
});
|
||||
|
||||
test("ignores member-like words inside component line and block comments", () => {
|
||||
const source = `component Counter {
|
||||
// Props are passed as attributes on the mount element.
|
||||
/* State and View are explained here, not declared here. */
|
||||
props {
|
||||
start = 0
|
||||
label = "Count"
|
||||
}
|
||||
state count = start
|
||||
view { <button>{label}: {count}</button> }
|
||||
}`;
|
||||
const declaration = findTopLevelDeclaration({}, source);
|
||||
|
||||
assert.deepEqual(
|
||||
validateRootMembers(mockDocument(source), source, declaration.kind, declaration.match),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores HTML-like tags inside WRN comments", () => {
|
||||
const source = `// The <section> below listens for child events.
|
||||
// <ComboBox> is only documentation in this comment.
|
||||
page Test {
|
||||
/*
|
||||
Example markup: <article><strong>Preview</strong></article>
|
||||
*/
|
||||
view {
|
||||
<main><section>Real content</section></main>
|
||||
}
|
||||
}`;
|
||||
|
||||
assert.deepEqual(validateHtmlTags(mockDocument(source), source), []);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,15 @@ test("preserves nested prop defaults while formatting", () => {
|
||||
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2 }), formatted);
|
||||
});
|
||||
|
||||
test("formats public event declarations as individual props members", () => {
|
||||
const source = `component Search {\nprops { value = "" @event search = function @event clear = function }\nview { <input/> }\n}\n`;
|
||||
const formatted = formatWrn(source, { insertSpaces: true, tabSize: 2 });
|
||||
|
||||
assert.match(formatted, / {4}@event search = function/);
|
||||
assert.match(formatted, / {4}@event clear = function/);
|
||||
assert.equal(formatWrn(formatted, { insertSpaces: true, tabSize: 2 }), formatted);
|
||||
});
|
||||
|
||||
test("keeps long inline-closing tags idempotent after multiline expansion", () => {
|
||||
const source = `component Status {
|
||||
view {
|
||||
@@ -37,3 +46,139 @@ test("keeps long inline-closing tags idempotent after multiline expansion", () =
|
||||
assert.equal(formatWrn(formatted, options), formatted);
|
||||
assert.match(formatted, /<\/span>Loading/);
|
||||
});
|
||||
|
||||
test("formats inline elements with one attribute per line", () => {
|
||||
const source = `component Button {
|
||||
view {
|
||||
<button type="button" class="primary" @click='save()'>Save</button>
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const expected = `component Button {
|
||||
view {
|
||||
<button
|
||||
type="button"
|
||||
class="primary"
|
||||
@click='save()'
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const options = {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
printWidth: 100,
|
||||
multilineAttributes: true,
|
||||
};
|
||||
|
||||
assert.equal(formatWrn(source, options), expected);
|
||||
|
||||
assert.equal(formatWrn(expected, options), expected);
|
||||
});
|
||||
|
||||
test("formats self-closing component props", () => {
|
||||
const source = `component Demo {
|
||||
view {
|
||||
<FeatureList items='{[{ label: "A" }]}' emptyLabel="No items" />
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const formatted = formatWrn(source, {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
printWidth: 100,
|
||||
multilineAttributes: true,
|
||||
});
|
||||
|
||||
assert.match(formatted, /<FeatureList\n/);
|
||||
|
||||
assert.match(formatted, /items='\{\[\{ label: "A" \}\]\}'/);
|
||||
|
||||
assert.match(formatted, /^\s*\/>$/m);
|
||||
});
|
||||
|
||||
test("indents if and each blocks", () => {
|
||||
const source = `page Demo {
|
||||
view {
|
||||
{#if open}
|
||||
{#each items as item}
|
||||
<span>{item.label}</span>
|
||||
{:empty}
|
||||
<p>Empty</p>
|
||||
{/each}
|
||||
{:else}
|
||||
<p>Closed</p>
|
||||
{/if}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const formatted = formatWrn(source, {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
multilineAttributes: true,
|
||||
});
|
||||
|
||||
assert.match(formatted, /^ {6}\{#each items as item\}$/m);
|
||||
|
||||
assert.match(formatted, /^ {8}<span>/m);
|
||||
|
||||
assert.equal(
|
||||
formatWrn(formatted, {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
multilineAttributes: true,
|
||||
}),
|
||||
formatted,
|
||||
);
|
||||
});
|
||||
|
||||
test("expands inline if blocks around HTML", () => {
|
||||
const source = `component Button {
|
||||
view {
|
||||
<button>
|
||||
{#if loading}<span class="wire-spinner wire-spinner--inline" aria-hidden="true"></span>{/if}
|
||||
{#if icon}<span class="{icon}"></span>{:else}<span>Fallback</span>{/if}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const expected = `component Button {
|
||||
view {
|
||||
<button>
|
||||
{#if loading}
|
||||
<span
|
||||
class="wire-spinner wire-spinner--inline"
|
||||
aria-hidden="true"
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
{#if icon}
|
||||
<span
|
||||
class="{icon}"
|
||||
>
|
||||
</span>
|
||||
{:else}
|
||||
<span>Fallback</span>
|
||||
{/if}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const options = {
|
||||
insertSpaces: true,
|
||||
tabSize: 2,
|
||||
printWidth: 100,
|
||||
multilineAttributes: true,
|
||||
};
|
||||
|
||||
assert.equal(formatWrn(source, options), expected);
|
||||
assert.equal(formatWrn(expected, options), expected);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user