@excom/quark-formatter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.rush/temp/chunked-rush-logs/quark-formatter.apply-exports.chunks.jsonl +1 -0
- package/.rush/temp/chunked-rush-logs/quark-formatter.build_package-metas.chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/all.log +1 -0
- package/.rush/temp/operation/apply-exports/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/apply-exports/state.json +3 -0
- package/.rush/temp/operation/build_package-metas/all.log +1 -0
- package/.rush/temp/operation/build_package-metas/log-chunks.jsonl +1 -0
- package/.rush/temp/operation/build_package-metas/state.json +3 -0
- package/.rush/temp/shrinkwrap-deps.json +3 -0
- package/config/rig.json +5 -0
- package/index.ts +1 -0
- package/package.json +41 -0
- package/rush-logs/quark-formatter.apply-exports.cache.log +1 -0
- package/rush-logs/quark-formatter.apply-exports.log +1 -0
- package/rush-logs/quark-formatter.build_package-metas.cache.log +1 -0
- package/rush-logs/quark-formatter.build_package-metas.log +1 -0
- package/src/doc.ts +297 -0
- package/src/printer.ts +625 -0
- package/support/docs/README.md +70 -0
- package/support/package-meta.json +33 -0
- package/support/tests/formatter.test.ts +976 -0
- package/tsconfig.json +5 -0
package/src/printer.ts
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Printer for the Quark AST.
|
|
3
|
+
*
|
|
4
|
+
* Output conventions match prettier's CSS/SCSS style: two-space indent,
|
|
5
|
+
* one selector per line in multi-selector rules, a single preserved blank
|
|
6
|
+
* line between statement groups, comments kept where they were written
|
|
7
|
+
* (including trailing same-line comments), and an 80-column print width.
|
|
8
|
+
* Statements are built as documents (`doc.ts`): parenthesised groups —
|
|
9
|
+
* maps, call arguments, `if()` arms, `@on` options — break one item per
|
|
10
|
+
* line when they overflow, operator chains and space lists wrap like text,
|
|
11
|
+
* a `key: value` inside them breaks after the colon when even
|
|
12
|
+
* `key: value(` will not fit, and a declaration whose value is a comma
|
|
13
|
+
* list of multi-word items breaks one item per line the way prettier
|
|
14
|
+
* prints `transition` / `grid-template-columns`. Strings, selectors and
|
|
15
|
+
* interpolations never wrap.
|
|
16
|
+
*
|
|
17
|
+
* The two Quark deviations from CSS are printed compactly, exactly as
|
|
18
|
+
* written: dot accessors (`$obj.field`) and bracket accessors
|
|
19
|
+
* (`$obj["field"]`) never receive surrounding whitespace.
|
|
20
|
+
*/
|
|
21
|
+
import {
|
|
22
|
+
type Doc,
|
|
23
|
+
fill,
|
|
24
|
+
group,
|
|
25
|
+
hardline,
|
|
26
|
+
indent,
|
|
27
|
+
indentIfBreak,
|
|
28
|
+
join,
|
|
29
|
+
line,
|
|
30
|
+
printDoc,
|
|
31
|
+
softline,
|
|
32
|
+
} from "./doc";
|
|
33
|
+
import type {
|
|
34
|
+
ActionRule,
|
|
35
|
+
Argument,
|
|
36
|
+
AtRule,
|
|
37
|
+
Binary,
|
|
38
|
+
Block,
|
|
39
|
+
CommentNode,
|
|
40
|
+
Declaration,
|
|
41
|
+
DelayRule,
|
|
42
|
+
EventName,
|
|
43
|
+
Expression,
|
|
44
|
+
Interpolation,
|
|
45
|
+
ListenerOption,
|
|
46
|
+
ListenerRule,
|
|
47
|
+
QuarkAtRuleName,
|
|
48
|
+
ScopeRule,
|
|
49
|
+
Selector,
|
|
50
|
+
SelectorList,
|
|
51
|
+
Statement,
|
|
52
|
+
Stylesheet,
|
|
53
|
+
TransitionRule,
|
|
54
|
+
UseRule,
|
|
55
|
+
ValueAtRule,
|
|
56
|
+
} from "@excom/quark-parser";
|
|
57
|
+
import { parse } from "@excom/quark-parser";
|
|
58
|
+
|
|
59
|
+
export interface FormatOptions {
|
|
60
|
+
/** Indentation unit. Defaults to two spaces. */
|
|
61
|
+
indent?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Format Quark source. Throws `QuarkParseError` on invalid input. */
|
|
65
|
+
export function format(source: string, options: FormatOptions = {}): string {
|
|
66
|
+
const ast = parse(source);
|
|
67
|
+
const lines = new Printer(source, options.indent ?? " ").stylesheet(ast);
|
|
68
|
+
return (
|
|
69
|
+
lines
|
|
70
|
+
.join("\n")
|
|
71
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
72
|
+
.trim() + "\n"
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Columns per line before a breakable construct wraps (prettier's default). */
|
|
77
|
+
const PRINT_WIDTH = 80;
|
|
78
|
+
|
|
79
|
+
/* Binding powers mirror the parser (`BINARY_BP`); used to re-insert
|
|
80
|
+
* the parentheses the AST no longer carries. */
|
|
81
|
+
const BINARY_BP: Record<string, number> = {
|
|
82
|
+
or: 1,
|
|
83
|
+
and: 2,
|
|
84
|
+
"==": 4,
|
|
85
|
+
"!=": 4,
|
|
86
|
+
"<": 5,
|
|
87
|
+
">": 5,
|
|
88
|
+
"<=": 5,
|
|
89
|
+
">=": 5,
|
|
90
|
+
"+": 6,
|
|
91
|
+
"-": 6,
|
|
92
|
+
"*": 7,
|
|
93
|
+
"/": 7,
|
|
94
|
+
"%": 7,
|
|
95
|
+
};
|
|
96
|
+
const NOT_BP = 3;
|
|
97
|
+
const UNARY_BP = 8;
|
|
98
|
+
const POSTFIX_BP = 9;
|
|
99
|
+
|
|
100
|
+
type AtRulePrinter<N extends QuarkAtRuleName> = (
|
|
101
|
+
printer: Printer,
|
|
102
|
+
node: Extract<AtRule, { name: N }>,
|
|
103
|
+
depth: number
|
|
104
|
+
) => string[];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* One printer per Quark at-rule, keyed by the parser's `QuarkAtRuleName`
|
|
108
|
+
* (`QUARK_AT_RULES`) so the two sets cannot drift: a name the parser adds
|
|
109
|
+
* or drops breaks this table at compile time.
|
|
110
|
+
*/
|
|
111
|
+
const AT_RULE_PRINTERS: { readonly [N in QuarkAtRuleName]: AtRulePrinter<N> } =
|
|
112
|
+
{
|
|
113
|
+
use: (p, node, depth) => p.useRule(node, depth),
|
|
114
|
+
scope: (p, node, depth) => p.scopeRule(node, depth),
|
|
115
|
+
on: (p, node, depth) => p.listenerRule(node, depth),
|
|
116
|
+
dispatch: (p, node, depth) => p.actionRule(node, depth),
|
|
117
|
+
command: (p, node, depth) => p.actionRule(node, depth),
|
|
118
|
+
"view-transition": (p, node, depth) => p.transitionRule(node, depth),
|
|
119
|
+
delay: (p, node, depth) => p.delayRule(node, depth),
|
|
120
|
+
warn: (p, node, depth) => p.valueAtRule(node, depth),
|
|
121
|
+
debug: (p, node, depth) => p.valueAtRule(node, depth),
|
|
122
|
+
error: (p, node, depth) => p.valueAtRule(node, depth),
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/** The table's entries seen through the widest node type they accept. */
|
|
126
|
+
type AtRulePrint = (printer: Printer, node: AtRule, depth: number) => string[];
|
|
127
|
+
|
|
128
|
+
class Printer {
|
|
129
|
+
constructor(
|
|
130
|
+
private source: string,
|
|
131
|
+
private indentUnit: string
|
|
132
|
+
) {}
|
|
133
|
+
|
|
134
|
+
private pad(depth: number): string {
|
|
135
|
+
return this.indentUnit.repeat(depth);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
stylesheet(ast: Stylesheet): string[] {
|
|
139
|
+
return this.body(ast.body, 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Lay a statement document out at `depth`, wrapping at the print width. */
|
|
143
|
+
private layout(doc: Doc, depth: number): string[] {
|
|
144
|
+
return printDoc(doc, {
|
|
145
|
+
width: PRINT_WIDTH,
|
|
146
|
+
indentUnit: this.indentUnit,
|
|
147
|
+
rootIndent: this.pad(depth),
|
|
148
|
+
}).split("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Render a document on one line (for contexts that never wrap). */
|
|
152
|
+
private flat(doc: Doc): string {
|
|
153
|
+
return printDoc(doc, {
|
|
154
|
+
width: Infinity,
|
|
155
|
+
indentUnit: this.indentUnit,
|
|
156
|
+
rootIndent: "",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/*
|
|
161
|
+
* ---------------------------------------------------------------------
|
|
162
|
+
* Statements
|
|
163
|
+
* ---------------------------------------------------------------------
|
|
164
|
+
*/
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Print a statement list, preserving single blank lines between groups
|
|
168
|
+
* and re-attaching comments that trailed a statement on the same line.
|
|
169
|
+
*/
|
|
170
|
+
private body(statements: Statement[], depth: number): string[] {
|
|
171
|
+
const lines: string[] = [];
|
|
172
|
+
let prevEnd = -1;
|
|
173
|
+
for (const node of statements) {
|
|
174
|
+
const gap =
|
|
175
|
+
prevEnd >= 0 ? this.source.slice(prevEnd, node.start) : undefined;
|
|
176
|
+
if (
|
|
177
|
+
node.type === "comment" &&
|
|
178
|
+
gap !== undefined &&
|
|
179
|
+
!gap.includes("\n") &&
|
|
180
|
+
lines.length
|
|
181
|
+
) {
|
|
182
|
+
// Trailing comment on the same line as the previous statement.
|
|
183
|
+
lines[lines.length - 1] += " " + this.comment(node, depth, true);
|
|
184
|
+
prevEnd = node.end;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (gap !== undefined && countNewlines(gap) >= 2) {
|
|
188
|
+
lines.push("");
|
|
189
|
+
}
|
|
190
|
+
lines.push(...this.statement(node, depth));
|
|
191
|
+
prevEnd = node.end;
|
|
192
|
+
}
|
|
193
|
+
return lines;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private statement(node: Statement, depth: number): string[] {
|
|
197
|
+
switch (node.type) {
|
|
198
|
+
case "comment":
|
|
199
|
+
return [this.pad(depth) + this.comment(node, depth, false)];
|
|
200
|
+
case "declaration":
|
|
201
|
+
return this.declaration(node, depth);
|
|
202
|
+
case "rule":
|
|
203
|
+
return this.withBlock(
|
|
204
|
+
this.selectorHead(node.selector),
|
|
205
|
+
node.block,
|
|
206
|
+
depth
|
|
207
|
+
);
|
|
208
|
+
case "atrule":
|
|
209
|
+
return (AT_RULE_PRINTERS[node.name] as AtRulePrint)(this, node, depth);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `head { ...body }` with the head's last line receiving the brace. */
|
|
214
|
+
private withBlock(head: Doc, block: Block, depth: number): string[] {
|
|
215
|
+
const headLines = this.layout([head, " {"], depth);
|
|
216
|
+
const inner = this.body(block.body, depth + 1);
|
|
217
|
+
return [...headLines, ...inner, this.pad(depth) + "}"];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private declaration(node: Declaration, depth: number): string[] {
|
|
221
|
+
const prop =
|
|
222
|
+
node.property.type === "variable"
|
|
223
|
+
? "$" + node.property.name
|
|
224
|
+
: node.property.name;
|
|
225
|
+
return this.layout(
|
|
226
|
+
[prop, ": ", this.declarationValue(node.value, prop), ";"],
|
|
227
|
+
depth
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* prettier's multi-value rule: a top-level comma list whose items are
|
|
233
|
+
* themselves multi-word (`transition: opacity 1s, color 1s`) breaks one
|
|
234
|
+
* item per line; custom properties (`--x`) are exempt.
|
|
235
|
+
*/
|
|
236
|
+
private declarationValue(value: Expression, prop: string): Doc {
|
|
237
|
+
if (
|
|
238
|
+
value.type === "list" &&
|
|
239
|
+
value.separator === "," &&
|
|
240
|
+
!value.parens &&
|
|
241
|
+
!value.brackets &&
|
|
242
|
+
!prop.startsWith("--") &&
|
|
243
|
+
value.items.some(isMultiWord)
|
|
244
|
+
) {
|
|
245
|
+
const items = value.items.map((item) => this.expr(item));
|
|
246
|
+
return indent([hardline, join([",", hardline], items)]);
|
|
247
|
+
}
|
|
248
|
+
return this.expr(value);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private comment(node: CommentNode, depth: number, trailing: boolean): string {
|
|
252
|
+
if (!node.text.includes("\n")) {
|
|
253
|
+
return "/*" + node.text + "*/";
|
|
254
|
+
}
|
|
255
|
+
if (trailing) return "/*" + node.text + "*/";
|
|
256
|
+
/* Multi-line block comment: shift interior lines from the
|
|
257
|
+
* comment's original column to the current indent. */
|
|
258
|
+
const lineStart = this.source.lastIndexOf("\n", node.start) + 1;
|
|
259
|
+
const originalIndent = this.source
|
|
260
|
+
.slice(lineStart, node.start)
|
|
261
|
+
.match(/^\s*/)![0];
|
|
262
|
+
const shifted = node.text
|
|
263
|
+
.split("\n")
|
|
264
|
+
.map((line, i) => {
|
|
265
|
+
if (i === 0) return line;
|
|
266
|
+
return line.startsWith(originalIndent)
|
|
267
|
+
? this.pad(depth) + line.slice(originalIndent.length)
|
|
268
|
+
: line;
|
|
269
|
+
})
|
|
270
|
+
.join("\n");
|
|
271
|
+
return "/*" + shifted + "*/";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/*
|
|
275
|
+
* ---------------------------------------------------------------------
|
|
276
|
+
* At-rules (dispatched through `AT_RULE_PRINTERS`)
|
|
277
|
+
* ---------------------------------------------------------------------
|
|
278
|
+
*/
|
|
279
|
+
|
|
280
|
+
/** `@use "/x" as ns;` — a `with (…)` configuration is not Quark. */
|
|
281
|
+
useRule(node: UseRule, depth: number): string[] {
|
|
282
|
+
const ns =
|
|
283
|
+
node.namespace === null
|
|
284
|
+
? ""
|
|
285
|
+
: node.namespace === "*"
|
|
286
|
+
? " as *"
|
|
287
|
+
: ` as ${node.namespace}`;
|
|
288
|
+
return this.layout(`@use "${node.url}"${ns};`, depth);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** `@scope { … }` — block only, no prelude. */
|
|
292
|
+
scopeRule(node: ScopeRule, depth: number): string[] {
|
|
293
|
+
return this.withBlock("@scope", node.block, depth);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* `@on input, change (debounce: 300, handle: save) { … }` /
|
|
298
|
+
* `@on submit (prevent-default);`: names as written, options as
|
|
299
|
+
* expressions, then a block like a rule or the statement's `;`.
|
|
300
|
+
*/
|
|
301
|
+
listenerRule(node: ListenerRule, depth: number): string[] {
|
|
302
|
+
const head: Doc = [
|
|
303
|
+
"@on ",
|
|
304
|
+
this.nameList(node.events),
|
|
305
|
+
this.optionsGroup(node.options),
|
|
306
|
+
];
|
|
307
|
+
return node.block
|
|
308
|
+
? this.withBlock(head, node.block, depth)
|
|
309
|
+
: this.layout([head, ";"], depth);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** `@dispatch cart-add (detail: $d, target: "#x");`, statements only. */
|
|
313
|
+
actionRule(node: ActionRule, depth: number): string[] {
|
|
314
|
+
return this.layout(
|
|
315
|
+
[
|
|
316
|
+
`@${node.name} `,
|
|
317
|
+
this.nameList(node.names),
|
|
318
|
+
this.optionsGroup(node.options),
|
|
319
|
+
";",
|
|
320
|
+
],
|
|
321
|
+
depth
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** `@view-transition (types: "t", timeout: 500) { … }`, always a block. */
|
|
326
|
+
transitionRule(node: TransitionRule, depth: number): string[] {
|
|
327
|
+
return this.withBlock(
|
|
328
|
+
["@view-transition", this.optionsGroup(node.options)],
|
|
329
|
+
node.block,
|
|
330
|
+
depth
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** `@delay 2000 { … }`: one duration expression, always a block. */
|
|
335
|
+
delayRule(node: DelayRule, depth: number): string[] {
|
|
336
|
+
return this.withBlock(
|
|
337
|
+
["@delay ", this.expr(node.duration)],
|
|
338
|
+
node.block,
|
|
339
|
+
depth
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** `@debug` / `@warn` / `@error`, one expression each. */
|
|
344
|
+
valueAtRule(node: ValueAtRule, depth: number): string[] {
|
|
345
|
+
return this.layout([`@${node.name} `, this.expr(node.value), ";"], depth);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private argument(a: Argument): Doc {
|
|
349
|
+
return [
|
|
350
|
+
a.name ? `$${a.name}: ` : "",
|
|
351
|
+
this.expr(a.value),
|
|
352
|
+
a.spread ? "..." : "",
|
|
353
|
+
];
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/*
|
|
357
|
+
* ---------------------------------------------------------------------
|
|
358
|
+
* Selectors
|
|
359
|
+
* ---------------------------------------------------------------------
|
|
360
|
+
*/
|
|
361
|
+
|
|
362
|
+
/** Rule heads put each selector on its own line. */
|
|
363
|
+
private selectorHead(list: SelectorList): Doc {
|
|
364
|
+
return join(
|
|
365
|
+
[",", hardline],
|
|
366
|
+
list.selectors.map((s) => this.selector(s))
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** `, `-joined, for `:not(...)` and the other selector pseudos. */
|
|
371
|
+
private selectorsInline(list: SelectorList): string {
|
|
372
|
+
return list.selectors.map((s) => this.selector(s)).join(", ");
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private selector(selector: Selector): string {
|
|
376
|
+
let out = "";
|
|
377
|
+
for (const part of selector.parts) {
|
|
378
|
+
switch (part.type) {
|
|
379
|
+
case "combinator":
|
|
380
|
+
out = out === "" ? "" : out + " ";
|
|
381
|
+
if (part.value !== " ") out += part.value + " ";
|
|
382
|
+
break;
|
|
383
|
+
case "type_selector":
|
|
384
|
+
out += part.name;
|
|
385
|
+
break;
|
|
386
|
+
case "class_selector":
|
|
387
|
+
out += "." + part.name;
|
|
388
|
+
break;
|
|
389
|
+
case "id_selector":
|
|
390
|
+
out += "#" + part.name;
|
|
391
|
+
break;
|
|
392
|
+
case "parent_selector":
|
|
393
|
+
out += "&" + (part.suffix ?? "");
|
|
394
|
+
break;
|
|
395
|
+
case "attribute_selector": {
|
|
396
|
+
const value =
|
|
397
|
+
part.operator && part.value
|
|
398
|
+
? part.operator + this.flat(this.expr(part.value))
|
|
399
|
+
: "";
|
|
400
|
+
const modifier = part.modifier ? " " + part.modifier : "";
|
|
401
|
+
out += `[${part.name}${value}${modifier}]`;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case "pseudo_class_selector": {
|
|
405
|
+
let argument = "";
|
|
406
|
+
if (part.argument) {
|
|
407
|
+
argument =
|
|
408
|
+
part.argument.type === "selector_list"
|
|
409
|
+
? `(${this.selectorsInline(part.argument)})`
|
|
410
|
+
: `(${collapseWs(part.argument.value)})`;
|
|
411
|
+
}
|
|
412
|
+
out += `:${part.name}${argument}`;
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
case "pseudo_element_selector":
|
|
416
|
+
out += `::${part.name}${
|
|
417
|
+
part.argument ? `(${collapseWs(part.argument.value)})` : ""
|
|
418
|
+
}`;
|
|
419
|
+
break;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/*
|
|
426
|
+
* ---------------------------------------------------------------------
|
|
427
|
+
* Expressions
|
|
428
|
+
* ---------------------------------------------------------------------
|
|
429
|
+
*/
|
|
430
|
+
|
|
431
|
+
private interpolation(node: Interpolation): string {
|
|
432
|
+
return `#{${this.flat(this.expr(node.expression))}}`;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Event / command names as authored: `click, "my:evt", --refresh`. */
|
|
436
|
+
private nameList(names: EventName[]): Doc {
|
|
437
|
+
return join(
|
|
438
|
+
", ",
|
|
439
|
+
names.map((n) => (n.quoted ? `"${n.name}"` : n.name))
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* ` (once, target: "li", debounce: 300)` after `@on <event>` or
|
|
445
|
+
* `@view-transition`: flags bare, values as expressions, one per line
|
|
446
|
+
* when the group overflows; an empty group prints nothing.
|
|
447
|
+
*/
|
|
448
|
+
private optionsGroup(options: ListenerOption[]): Doc {
|
|
449
|
+
return options.length
|
|
450
|
+
? [
|
|
451
|
+
" ",
|
|
452
|
+
this.parenGroup(
|
|
453
|
+
options.map((o) =>
|
|
454
|
+
o.value ? this.pair(o.name, this.expr(o.value)) : o.name
|
|
455
|
+
),
|
|
456
|
+
","
|
|
457
|
+
),
|
|
458
|
+
]
|
|
459
|
+
: "";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* `(a, b, c)`: flat when it fits, otherwise one item per line with the
|
|
464
|
+
* delimiters on their own lines. `separator` is `","` / `";"` / `""`.
|
|
465
|
+
*/
|
|
466
|
+
private parenGroup(
|
|
467
|
+
items: Doc[],
|
|
468
|
+
separator: string,
|
|
469
|
+
open = "(",
|
|
470
|
+
close = ")"
|
|
471
|
+
): Doc {
|
|
472
|
+
return group([
|
|
473
|
+
open,
|
|
474
|
+
indent([softline, join([separator, line], items)]),
|
|
475
|
+
softline,
|
|
476
|
+
close,
|
|
477
|
+
]);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* `key: value` inside a paren group (`if()` arms, map entries, `@on`
|
|
482
|
+
* options). prettier's "fluid" assignment layout: the value hugs the key
|
|
483
|
+
* when `key: value(` fits on the line, otherwise the line breaks after the
|
|
484
|
+
* colon and the value is indented.
|
|
485
|
+
*/
|
|
486
|
+
private pair(key: Doc, value: Doc): Doc {
|
|
487
|
+
const id = Symbol("pair");
|
|
488
|
+
return group([key, ":", group(indent(line), id), indentIfBreak(value, id)]);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Print an expression. `parentBp`/`side` re-create the parentheses that
|
|
493
|
+
* precedence made necessary in the original source.
|
|
494
|
+
*/
|
|
495
|
+
private expr(node: Expression, parentBp = 0, side?: "right"): Doc {
|
|
496
|
+
switch (node.type) {
|
|
497
|
+
case "string": {
|
|
498
|
+
const inner =
|
|
499
|
+
node.value !== null
|
|
500
|
+
? node.value
|
|
501
|
+
: node.parts
|
|
502
|
+
.map((part) =>
|
|
503
|
+
typeof part === "string" ? part : this.interpolation(part)
|
|
504
|
+
)
|
|
505
|
+
.join("");
|
|
506
|
+
return node.quote + inner + node.quote;
|
|
507
|
+
}
|
|
508
|
+
case "number":
|
|
509
|
+
return `${node.value}${node.unit ?? ""}`;
|
|
510
|
+
case "color":
|
|
511
|
+
return node.value;
|
|
512
|
+
case "boolean":
|
|
513
|
+
return String(node.value);
|
|
514
|
+
case "null":
|
|
515
|
+
return "null";
|
|
516
|
+
case "identifier":
|
|
517
|
+
return node.name;
|
|
518
|
+
case "variable":
|
|
519
|
+
return "$" + node.name;
|
|
520
|
+
case "parent_reference":
|
|
521
|
+
return "&";
|
|
522
|
+
case "interpolation":
|
|
523
|
+
return this.interpolation(node);
|
|
524
|
+
case "url":
|
|
525
|
+
return `url(${node.parts
|
|
526
|
+
.map((part) =>
|
|
527
|
+
typeof part === "string" ? part : this.interpolation(part)
|
|
528
|
+
)
|
|
529
|
+
.join("")})`;
|
|
530
|
+
case "member":
|
|
531
|
+
return [
|
|
532
|
+
this.expr(node.object, POSTFIX_BP),
|
|
533
|
+
`.${node.variable ? "$" : ""}${node.property}`,
|
|
534
|
+
];
|
|
535
|
+
case "index":
|
|
536
|
+
return [
|
|
537
|
+
this.expr(node.object, POSTFIX_BP),
|
|
538
|
+
"[",
|
|
539
|
+
this.expr(node.index),
|
|
540
|
+
"]",
|
|
541
|
+
];
|
|
542
|
+
case "function":
|
|
543
|
+
return [
|
|
544
|
+
this.expr(node.callee),
|
|
545
|
+
node.args.length
|
|
546
|
+
? this.parenGroup(
|
|
547
|
+
node.args.map((a) => this.argument(a)),
|
|
548
|
+
","
|
|
549
|
+
)
|
|
550
|
+
: "()",
|
|
551
|
+
];
|
|
552
|
+
case "if": {
|
|
553
|
+
const arms = node.arms.map((arm) =>
|
|
554
|
+
this.pair(
|
|
555
|
+
arm.condition === null ? "else" : this.expr(arm.condition),
|
|
556
|
+
this.expr(arm.value)
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
return ["if", this.parenGroup(arms, ";")];
|
|
560
|
+
}
|
|
561
|
+
case "unary": {
|
|
562
|
+
if (node.operator === "not") {
|
|
563
|
+
const doc: Doc = ["not ", this.expr(node.argument, NOT_BP)];
|
|
564
|
+
return parentBp > NOT_BP ? ["(", doc, ")"] : doc;
|
|
565
|
+
}
|
|
566
|
+
const doc: Doc = [node.operator, this.expr(node.argument, UNARY_BP)];
|
|
567
|
+
// `(-$a).b`: a signed operand of a postfix accessor keeps its parens.
|
|
568
|
+
return parentBp > UNARY_BP ? ["(", doc, ")"] : doc;
|
|
569
|
+
}
|
|
570
|
+
case "binary":
|
|
571
|
+
// `$a and $b or $c` wraps like text, breaking after an operator.
|
|
572
|
+
return group(indent(fill(this.binaryParts(node, parentBp, side))));
|
|
573
|
+
case "list": {
|
|
574
|
+
const items = node.items.map((item) => this.expr(item));
|
|
575
|
+
const separator = node.separator === "," ? "," : "";
|
|
576
|
+
if (node.brackets) return this.parenGroup(items, separator, "[", "]");
|
|
577
|
+
/* A bare comma/space list used as an operand needs parens to
|
|
578
|
+
* keep its grouping against surrounding operators. */
|
|
579
|
+
if (node.parens || parentBp > 0)
|
|
580
|
+
return this.parenGroup(items, separator);
|
|
581
|
+
return group(indent(fill(join([separator, line], items))));
|
|
582
|
+
}
|
|
583
|
+
case "map": {
|
|
584
|
+
const entries = node.entries.map((entry) =>
|
|
585
|
+
this.pair(this.expr(entry.key), this.expr(entry.value))
|
|
586
|
+
);
|
|
587
|
+
return this.parenGroup(entries, ",");
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Flatten an operator chain into `fill` parts (`[a and, line, b or, line, c]`).
|
|
594
|
+
* A child that precedence parenthesised in the source becomes one part.
|
|
595
|
+
*/
|
|
596
|
+
private binaryParts(node: Binary, parentBp: number, side?: "right"): Doc[] {
|
|
597
|
+
const bp = BINARY_BP[node.operator];
|
|
598
|
+
const parts = this.operandParts(node.left, bp);
|
|
599
|
+
parts[parts.length - 1] = [parts[parts.length - 1], " ", node.operator];
|
|
600
|
+
parts.push(line, ...this.operandParts(node.right, bp, "right"));
|
|
601
|
+
/* Lower-precedence child, or equal precedence on the right of
|
|
602
|
+
* a left-associative operator, was parenthesized in the source. */
|
|
603
|
+
return bp < parentBp || (bp === parentBp && side === "right")
|
|
604
|
+
? [group(["(", indent([softline, fill(parts)]), softline, ")"])]
|
|
605
|
+
: parts;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
private operandParts(node: Expression, bp: number, side?: "right"): Doc[] {
|
|
609
|
+
return node.type === "binary"
|
|
610
|
+
? this.binaryParts(node, bp, side)
|
|
611
|
+
: [this.expr(node, bp, side)];
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Space list or operator chain — prettier's "comma group" of several words. */
|
|
616
|
+
const isMultiWord = (node: Expression): boolean =>
|
|
617
|
+
(node.type === "list" &&
|
|
618
|
+
node.separator === " " &&
|
|
619
|
+
!node.parens &&
|
|
620
|
+
!node.brackets) ||
|
|
621
|
+
node.type === "binary";
|
|
622
|
+
|
|
623
|
+
const countNewlines = (text: string): number => text.split("\n").length - 1;
|
|
624
|
+
|
|
625
|
+
const collapseWs = (text: string): string => text.replace(/\s+/g, " ").trim();
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# quark-formatter
|
|
2
|
+
|
|
3
|
+
Prettier-style formatting for Quark sheets — one call, one canonical style, nothing to configure.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
import { format } from "@excom/quark-formatter";
|
|
7
|
+
|
|
8
|
+
format(`provider-fetch[is-success]{$todos:prop("provision").body;ul{content:iterate($todos)}}`);
|
|
9
|
+
// provider-fetch[is-success] {
|
|
10
|
+
// $todos: prop("provision").body;
|
|
11
|
+
// ul {
|
|
12
|
+
// content: iterate($todos);
|
|
13
|
+
// }
|
|
14
|
+
// }
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Features
|
|
18
|
+
|
|
19
|
+
- **One style** Two-space indent, 80-column wrapping, one selector per line, single blank lines between groups
|
|
20
|
+
- **Safe** Invalid Quark throws instead of rewriting; comments stay where they were written; formatting is idempotent
|
|
21
|
+
- **Editor / CLI ready** Powers Format Document in the [Nucleus & Quark extension](/nucleus/packages/nucleus-quark-highlighter) and the monorepo `format` script
|
|
22
|
+
- **Self-contained** Parser bundled in; runs in Node, bundlers and browsers
|
|
23
|
+
|
|
24
|
+
## Installation
|
|
25
|
+
|
|
26
|
+
<include-content is-active template-ref="/views/install-section/install-section.html"></include-content>
|
|
27
|
+
|
|
28
|
+
## Usage
|
|
29
|
+
|
|
30
|
+
`format(source, options?)` returns the formatted sheet as a string. The only option is `indent` (default two spaces).
|
|
31
|
+
|
|
32
|
+
<include-content data-language="js"><template>import { format } from "@excom/quark-formatter";
|
|
33
|
+
|
|
34
|
+
const pretty = format(source, { indent: "\t" });</template></include-content>
|
|
35
|
+
|
|
36
|
+
Invalid input throws `QuarkParseError` (from `@excom/quark-parser`) with the line and column, so callers leave the original file untouched:
|
|
37
|
+
|
|
38
|
+
<include-content data-language="js"><template>try {
|
|
39
|
+
fs.writeFileSync(file, format(fs.readFileSync(file, "utf8")));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`${file}: ${error.message}`);
|
|
42
|
+
}</template></include-content>
|
|
43
|
+
|
|
44
|
+
### What gets normalized
|
|
45
|
+
|
|
46
|
+
- Whitespace, indentation and blank lines (at most one preserved between statements)
|
|
47
|
+
- Selector lists: one per line at rule heads, `, `-joined inside `:not()` / `:is()`
|
|
48
|
+
- Operator spacing, with only the parentheses the expression needs: `($a or $b) and $c`
|
|
49
|
+
- Accessors and call chains print compactly: `$todo.title`, `$row["id"]`, `closest("li").getAttribute("id")`
|
|
50
|
+
- Comments (`/* … */` only) keep their position, including trailing same-line comments
|
|
51
|
+
- Lines wrap at 80 columns the way prettier wraps CSS: maps, call arguments, `if()` arms, `@on` / `@dispatch` / `@command` / `@view-transition` options and `@delay` durations that overflow break one item per line, operator chains wrap like text, a comma list of multi-word values breaks one value per line
|
|
52
|
+
|
|
53
|
+
```quark
|
|
54
|
+
dataset: (
|
|
55
|
+
trip: event.target.form.elements["data-trip"].value,
|
|
56
|
+
outbound: event.target.form.elements["data-outbound"].value
|
|
57
|
+
);
|
|
58
|
+
data-mode: if(
|
|
59
|
+
event.target.name == "data-mode": event.target.value;
|
|
60
|
+
else: preserve
|
|
61
|
+
);
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Strings, selectors and interpolations never wrap, so a long `content: "…#{…}…"` stays on one line. Listener at-rules print as `@on input, change (debounce: 300, handle: save) {` — the event list as written, the options group like a map — and `@dispatch` / `@command` statements the same way; `@view-transition (types: "todo-change") {` prints its options like `@on`'s and always opens a block, as does `@scope {`.
|
|
65
|
+
|
|
66
|
+
Quark is a derivative of CSS, not a superset: the formatter prints Quark's own at-rules — `@use`, `@scope`, `@on`, `@dispatch`, `@command`, `@view-transition`, `@delay`, `@warn`, `@debug`, `@error` — and nothing else. Anything the parser rejects (`@media` and the other CSS at-rules, SCSS's `@if` / `@each` / `@mixin`, `%placeholder` selectors, `#{…}` outside a string, `!important` / `!default`, nested property blocks, `@use … with (…)`) throws rather than being reformatted.
|
|
67
|
+
|
|
68
|
+
### In the editor
|
|
69
|
+
|
|
70
|
+
Install the [Nucleus & Quark Syntax Highlighter](/nucleus/packages/nucleus-quark-highlighter) to format `.quark` files with Format Document and inline `<quark-sheet>` blocks with a command.
|